Skip to content

Commit 03c4281

Browse files
authored
feat: add Humanode verification callback (#466)
Add a Humanode OAuth callback that will give a "free storage with no credit card" plan to a user who uses Humanode to prove they are a unique person. <img width="1220" alt="Screenshot 2025-04-10 at 3 42 22 PM" src="https://github.com/user-attachments/assets/3ae37e80-dd0c-4348-8ad2-1761ce4213cb" /> <img width="1220" alt="Screenshot 2025-04-10 at 3 43 09 PM" src="https://github.com/user-attachments/assets/dddcc6da-2863-465f-9c1c-808f40a172e1" /> <img width="1225" alt="Screenshot 2025-04-10 at 3 44 11 PM" src="https://github.com/user-attachments/assets/30f13cd5-4c52-4858-84ad-738d9b658c6d" /> <img width="1220" alt="Screenshot 2025-04-10 at 3 43 19 PM" src="https://github.com/user-attachments/assets/b56609c3-ea21-4e15-a09a-12c3f9c71a95" /> The one remaining concern I have with this code is the duplication in the templates - we now have 4 blobs of HTML like this across the two OAuth handlers and I'm not sure the best way to consolidate - any thoughts @alanshaw ?
1 parent 1990bdc commit 03c4281

File tree

9 files changed

+368
-3
lines changed

9 files changed

+368
-3
lines changed

.env.tpl

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,3 +75,6 @@ INTEGRATION_TESTS_STORAGE_PROVIDER_PROOF = ''
7575
# Optional - custom gateway to authorize in integration tests (defaults to staging)
7676
INTEGRATION_TESTS_GATEWAY_DID = ''
7777
INTEGRATION_TESTS_GATEWAY_ENDPOINT = ''
78+
79+
HUMANODE_TOKEN_ENDPOINT='https://auth.demo-storacha-2025-03-31.oauth2.humanode.io/oauth2/token'
80+
HUMANODE_CLIENT_ID='e9756297-b2d1-4bbe-a139-a9ad1cdc43ee'

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@
6969
},
7070
"dependencies": {
7171
"aws-cdk-lib": "catalog:",
72+
"jwt-decode": "^4.0.0",
7273
"sst": "catalog:"
7374
},
7475
"simple-git-hooks": {

pnpm-lock.yaml

Lines changed: 9 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

stacks/upload-api-stack.js

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ export function UploadApiStack({ stack, app }) {
4343

4444
// Get references to constructs created in other stacks
4545
const { carparkBucket } = use(CarparkStack)
46-
const { allocationTable, blobRegistryTable, storeTable, uploadTable, delegationBucket, delegationTable, revocationTable, adminMetricsTable, spaceMetricsTable, consumerTable, subscriptionTable, storageProviderTable, rateLimitTable, pieceTable, privateKey, indexingServiceProof, githubClientSecret } = use(UploadDbStack)
46+
const { allocationTable, blobRegistryTable, humanodeTable, storeTable, uploadTable, delegationBucket, delegationTable, revocationTable, adminMetricsTable, spaceMetricsTable, consumerTable, subscriptionTable, storageProviderTable, rateLimitTable, pieceTable, privateKey, indexingServiceProof, githubClientSecret, humanodeClientSecret } = use(UploadDbStack)
4747
const { agentIndexBucket, agentMessageBucket, ucanStream } = use(UcanInvocationStack)
4848
const { customerTable, spaceDiffTable, spaceSnapshotTable, egressTrafficTable, stripeSecretKey } = use(BillingDbStack)
4949
const { pieceOfferQueue, filecoinSubmitQueue } = use(FilecoinStack)
@@ -70,6 +70,7 @@ export function UploadApiStack({ stack, app }) {
7070
permissions: [
7171
allocationTable, // legacy
7272
blobRegistryTable,
73+
humanodeTable,
7374
storeTable, // legacy
7475
uploadTable,
7576
customerTable,
@@ -105,6 +106,7 @@ export function UploadApiStack({ stack, app }) {
105106
UPLOAD_TABLE_NAME: uploadTable.tableName,
106107
CONSUMER_TABLE_NAME: consumerTable.tableName,
107108
CUSTOMER_TABLE_NAME: customerTable.tableName,
109+
HUMANODE_TABLE_NAME: humanodeTable.tableName,
108110
SUBSCRIPTION_TABLE_NAME: subscriptionTable.tableName,
109111
SPACE_METRICS_TABLE_NAME: spaceMetricsTable.tableName,
110112
ADMIN_METRICS_TABLE_NAME: adminMetricsTable.tableName,
@@ -151,12 +153,15 @@ export function UploadApiStack({ stack, app }) {
151153
HOSTED_ZONE: hostedZone ?? '',
152154
GITHUB_CLIENT_ID: process.env.GITHUB_CLIENT_ID ?? '',
153155
PRINCIPAL_MAPPING: process.env.PRINCIPAL_MAPPING ?? '',
156+
HUMANODE_TOKEN_ENDPOINT: process.env.HUMANODE_TOKEN_ENDPOINT ?? '',
157+
HUMANODE_CLIENT_ID: process.env.HUMANODE_CLIENT_ID ?? ''
154158
},
155159
bind: [
156160
privateKey,
157161
ucanInvocationPostbasicAuth,
158162
stripeSecretKey,
159163
githubClientSecret,
164+
humanodeClientSecret,
160165
indexingServiceProof,
161166
]
162167
}
@@ -177,6 +182,7 @@ export function UploadApiStack({ stack, app }) {
177182
'GET /metrics/{proxy+}': 'upload-api/functions/metrics.handler',
178183
'GET /sample': 'upload-api/functions/sample.handler',
179184
'GET /oauth/callback': 'upload-api/functions/oauth-callback.handler',
185+
'GET /oauth/humanode/callback': 'upload-api/functions/oauth-humanode-callback.handler',
180186
},
181187
accessLog: {
182188
format:'{"requestTime":"$context.requestTime","requestId":"$context.requestId","httpMethod":"$context.httpMethod","path":"$context.path","routeKey":"$context.routeKey","status":$context.status,"responseLatency":$context.responseLatency,"integrationRequestId":"$context.integration.requestId","integrationStatus":"$context.integration.status","integrationLatency":"$context.integration.latency","integrationServiceStatus":"$context.integration.integrationStatus","ip":"$context.identity.sourceIp","userAgent":"$context.identity.userAgent"}'

stacks/upload-db-stack.js

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,8 @@ import {
1212
rateLimitTableProps,
1313
adminMetricsTableProps,
1414
spaceMetricsTableProps,
15-
storageProviderTableProps
15+
storageProviderTableProps,
16+
humanodeTableProps
1617
} from '../upload-api/tables/index.js'
1718
import {
1819
pieceTableProps
@@ -34,6 +35,9 @@ export function UploadDbStack({ stack, app }) {
3435
const indexingServiceProof = new Config.Secret(stack, 'INDEXING_SERVICE_PROOF')
3536

3637
const githubClientSecret = new Config.Secret(stack, 'GITHUB_CLIENT_SECRET')
38+
const humanodeClientSecret = new Config.Secret(stack, 'HUMANODE_CLIENT_SECRET')
39+
40+
const humanodeTable = new Table(stack, 'humanode', humanodeTableProps)
3741

3842
/**
3943
* The allocation table tracks allocated multihashes per space.
@@ -122,6 +126,7 @@ export function UploadDbStack({ stack, app }) {
122126
return {
123127
allocationTable,
124128
blobRegistryTable,
129+
humanodeTable,
125130
storeTable,
126131
uploadTable,
127132
pieceTable,
@@ -136,6 +141,7 @@ export function UploadDbStack({ stack, app }) {
136141
storageProviderTable,
137142
privateKey,
138143
githubClientSecret,
144+
humanodeClientSecret,
139145
indexingServiceProof,
140146
}
141147
}
Lines changed: 238 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,238 @@
1+
import { Config } from 'sst/node/config'
2+
import * as Sentry from '@sentry/serverless'
3+
import { base64url } from 'multiformats/bases/base64'
4+
import { Delegation, ok } from '@ucanto/core'
5+
import * as Validator from '@ucanto/validator'
6+
import { Verifier } from '@ucanto/principal'
7+
import * as Access from '@storacha/capabilities/access'
8+
import * as DidMailto from '@storacha/did-mailto'
9+
import { mustGetEnv } from '../../lib/env.js'
10+
import { createCustomerStore } from '../../billing/tables/customer.js'
11+
import { getServiceSigner } from '../config.js'
12+
import { jwtDecode } from "jwt-decode"
13+
import { createHumanodesTable } from '../stores/humanodes.js'
14+
15+
16+
/**
17+
* @import { Endpoints } from '@octokit/types'
18+
* @import { Signer, Result } from '@ucanto/interface'
19+
* @typedef {{
20+
* getOAuthAccessToken: (params: { code: string }) => Promise<Result<{ access_token: string }>>
21+
* getUser: (params: { accessToken: string }) => Promise<Result<Endpoints['GET /user']['response']['data']>>
22+
* getUserEmails: (params: { accessToken: string }) => Promise<Result<Endpoints['GET /user/emails']['response']['data']>>
23+
* }} GitHub
24+
* @typedef {{
25+
* serviceSigner: Signer
26+
* customerStore: import('../../billing/lib/api.js').CustomerStore
27+
* humanodeStore: import('../types.js').HumanodeStore
28+
* }} Context
29+
*/
30+
31+
Sentry.AWSLambda.init({
32+
environment: process.env.SST_STAGE,
33+
dsn: process.env.SENTRY_DSN,
34+
tracesSampleRate: 0,
35+
})
36+
37+
const HUMANODE_TOKEN_ENDPOINT = mustGetEnv('HUMANODE_TOKEN_ENDPOINT')
38+
const HUMANODE_CLIENT_ID = mustGetEnv('HUMANODE_CLIENT_ID')
39+
const HUMANODE_CLIENT_SECRET = Config.HUMANODE_CLIENT_SECRET
40+
41+
/**
42+
* AWS HTTP Gateway handler for GET /oauth/humanode/callback.
43+
*
44+
* @param {import('aws-lambda').APIGatewayProxyEventV2} request
45+
* @param {import('aws-lambda').Context} [context]
46+
*/
47+
export const oauthCallbackGet = async (request, context) => {
48+
const {
49+
serviceSigner,
50+
customerStore,
51+
humanodeStore
52+
} = getContext(context?.clientContext?.Custom)
53+
54+
const code = request.queryStringParameters?.code
55+
if (!code) {
56+
console.error('missing code in query params')
57+
return htmlResponse(400, getUnexpectedErrorResponseHTML('Query params are missing code'))
58+
}
59+
60+
// fetch the auth token from Humanode
61+
const tokenResponse = await fetch(HUMANODE_TOKEN_ENDPOINT, {
62+
method: 'POST', body: new URLSearchParams(
63+
{
64+
client_id: HUMANODE_CLIENT_ID,
65+
client_secret: HUMANODE_CLIENT_SECRET,
66+
grant_type: "authorization_code",
67+
code,
68+
redirect_uri: `https://${request.headers.host}${request.rawPath}`
69+
}
70+
)
71+
})
72+
const tokenResult = await tokenResponse.json()
73+
const humanodeIdToken = jwtDecode(tokenResult.id_token)
74+
const humanodeId = humanodeIdToken.sub
75+
if (!humanodeId) {
76+
console.error("humanodeId is not undefined, this is very strange")
77+
return htmlResponse(500, getUnexpectedErrorResponseHTML('Failed to get Humanode ID'))
78+
}
79+
80+
const existsResponse = await humanodeStore.exists(humanodeId)
81+
if (existsResponse.error){
82+
return htmlResponse(500, getUnexpectedErrorResponseHTML(existsResponse.error.message))
83+
}
84+
if (existsResponse.ok){
85+
return htmlResponse(400, getDuplicateHumanodeResponseHTML())
86+
}
87+
88+
// validate the access/authorize delegation and pull the customer email out of it
89+
const extractRes = await Delegation.extract(base64url.decode(request.queryStringParameters?.state ?? ''))
90+
if (extractRes.error) {
91+
console.error('decoding access/authorize delegation', extractRes.error)
92+
return htmlResponse(400, getUnexpectedErrorResponseHTML('Failed to decode access/authorization delegation.'))
93+
}
94+
const authRequest =
95+
/** @type {import('@ucanto/interface').Invocation<import('@storacha/upload-api').AccessAuthorize>} */
96+
(extractRes.ok)
97+
const accessRes = await Validator.access(authRequest, {
98+
capability: Access.authorize,
99+
authority: serviceSigner,
100+
principal: Verifier,
101+
validateAuthorization: () => ok({})
102+
})
103+
if (accessRes.error) {
104+
console.error('validating access/authorize delegation', accessRes.error)
105+
106+
return htmlResponse(400, getUnexpectedErrorResponseHTML('Failed to validate access/authorization delegation.'))
107+
}
108+
109+
if (!accessRes.ok.capability.nb.iss) {
110+
return htmlResponse(400, getUnexpectedErrorResponseHTML('Account DID not included in authorize request.'))
111+
}
112+
113+
let customer
114+
try {
115+
customer = DidMailto.fromString(accessRes.ok.capability.nb.iss)
116+
} catch (e) {
117+
console.error("error parsing did:mailto:", e)
118+
return htmlResponse(400, getUnexpectedErrorResponseHTML('Invalid Account DID received.'))
119+
}
120+
121+
// add a customer with a trial product
122+
const customerPutRes = await customerStore.put({
123+
customer,
124+
product: 'did:web:trial.storacha.network',
125+
details: JSON.stringify({ humanode: { id: humanodeId } }),
126+
insertedAt: new Date()
127+
})
128+
if (!customerPutRes.ok) {
129+
console.error(`putting customer: ${customer}`, customerPutRes.error)
130+
return htmlResponse(500, getUnexpectedErrorResponseHTML('Failed to update customer store.'))
131+
}
132+
133+
await humanodeStore.add(humanodeId)
134+
135+
return htmlResponse(200, getResponseHTML())
136+
}
137+
138+
export const handler = Sentry.AWSLambda.wrapHandler((event) => oauthCallbackGet(event))
139+
140+
/**
141+
* @param {Context} [customContext]
142+
* @returns {Context}
143+
*/
144+
const getContext = (customContext) => {
145+
if (customContext) return customContext
146+
147+
const region = process.env.AWS_REGION || 'us-west-2'
148+
149+
const serviceSigner = getServiceSigner({
150+
did: process.env.UPLOAD_API_DID,
151+
privateKey: Config.PRIVATE_KEY
152+
})
153+
154+
const customerStore = createCustomerStore({ region }, { tableName: mustGetEnv('CUSTOMER_TABLE_NAME') })
155+
const humanodeStore = createHumanodesTable(region, mustGetEnv('HUMANODE_TABLE_NAME'))
156+
157+
return {
158+
serviceSigner,
159+
customerStore,
160+
humanodeStore
161+
}
162+
}
163+
164+
/**
165+
* @param {number} statusCode
166+
* @param {string} body
167+
* @returns
168+
*/
169+
function htmlResponse(statusCode, body) {
170+
return {
171+
statusCode,
172+
headers: { 'Content-Type': 'text/html' },
173+
body: Buffer.from(body).toString('base64'),
174+
isBase64Encoded: true,
175+
}
176+
}
177+
178+
const getResponseHTML = () => `
179+
<!doctype html>
180+
<html lang="en">
181+
<head>
182+
<title>Authorized - Storacha Network</title>
183+
</head>
184+
<body style="font-family:sans-serif;color:#000">
185+
<div style="height:100vh;display:flex;align-items:center;justify-content:center">
186+
<div style="text-align:center">
187+
<img src="https://w3s.link/ipfs/bafybeihinjwsn3kgjrlpdada4xingozsni3boywlscxspc5knatftauety/storacha-bug.svg" alt="Storacha - Decentralized Hot Storage Layer on Filecoin">
188+
<h1 style="font-weight:normal">Authorization Successful</h1>
189+
<p>You have been granted a free Storacha storage plan.</p>
190+
<p>You may now close this window.</p>
191+
</div>
192+
</div>
193+
</body>
194+
</html>
195+
`.trim()
196+
197+
const getDuplicateHumanodeResponseHTML = () => `
198+
<!doctype html>
199+
<html lang="en">
200+
<head>
201+
<title>Authorized - Storacha Network</title>
202+
</head>
203+
<body style="font-family:sans-serif;color:#000">
204+
<div style="height:100vh;display:flex;align-items:center;justify-content:center">
205+
<div style="text-align:center">
206+
<img src="https://w3s.link/ipfs/bafybeihinjwsn3kgjrlpdada4xingozsni3boywlscxspc5knatftauety/storacha-bug.svg" alt="Storacha - Decentralized Hot Storage Layer on Filecoin">
207+
<h1 style="font-weight:normal">Plan Selection Unsuccessful</h1>
208+
<p>The identified human has already claimed their free plan.</p>
209+
<p>You may now close this window.</p>
210+
</div>
211+
</div>
212+
</body>
213+
</html>
214+
`.trim()
215+
216+
/**
217+
*
218+
* @param {string} message
219+
* @returns
220+
*/
221+
const getUnexpectedErrorResponseHTML = (message) => `
222+
<!doctype html>
223+
<html lang="en">
224+
<head>
225+
<title>Authorized - Storacha Network</title>
226+
</head>
227+
<body style="font-family:sans-serif;color:#000">
228+
<div style="height:100vh;display:flex;align-items:center;justify-content:center">
229+
<div style="text-align:center">
230+
<img src="https://w3s.link/ipfs/bafybeihinjwsn3kgjrlpdada4xingozsni3boywlscxspc5knatftauety/storacha-bug.svg" alt="Storacha - Decentralized Hot Storage Layer on Filecoin">
231+
<h1 style="font-weight:normal">Unexpected Error</h1>
232+
<p>An unexpected error occured while trying to authenticate you: ${message} </p>
233+
<p>Please close this window and try again.</p>
234+
</div>
235+
</div>
236+
</body>
237+
</html>
238+
`.trim()

0 commit comments

Comments
 (0)