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