diff --git a/apps/backend/package.json b/apps/backend/package.json index e5ee77211..1087e85b1 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -55,10 +55,12 @@ "jose": "^5.2.2", "next": "^14.2.5", "nodemailer": "^6.9.10", + "oidc-provider": "^8.5.1", "openid-client": "^5.6.4", "oslo": "^1.2.1", "pg": "^8.11.3", "posthog-node": "^4.1.0", + "quick-lru": "^7.0.0", "react": "^18.2", "semver": "^7.6.3", "server-only": "^0.0.1", @@ -71,6 +73,7 @@ "@simplewebauthn/types": "^11.0.0", "@types/node": "^20.8.10", "@types/nodemailer": "^6.4.14", + "@types/oidc-provider": "^8.5.1", "@types/react": "^18.2.66", "@types/semver": "^7.5.8", "concurrently": "^8.2.2", diff --git a/apps/backend/src/app/api/v1/idp/[...route]/route.tsx b/apps/backend/src/app/api/v1/idp/[...route]/route.tsx new file mode 100644 index 000000000..7c34748d5 --- /dev/null +++ b/apps/backend/src/app/api/v1/idp/[...route]/route.tsx @@ -0,0 +1,40 @@ +import { interaction_route, oidc } from "@/idp/idp"; +import { IncomingMessage, ServerResponse } from "http"; +import { NextApiRequest } from "next"; +import { NextResponse } from "next/server"; +import { NextRequest } from "next/server"; + + +export async function GET(req: NextRequest) { + + console.log(req); + + let body = ""; + + const next_response = new NextResponse(); + const res = { + ...next_response, + removeHeader: (key: string) => { + console.log('removeHeader', key); + next_response.headers.delete(key); + }, + setHeader: (key: string, value: string) => { + console.log('setHeader', key, value); + next_response.headers.set(key, value); + }, + end: (chunk: any, encoding: any, callback: any) => { + console.log('end', chunk); + body += chunk; + }, + }; + + + const x = await oidc.callback()(req as unknown as IncomingMessage, res as unknown as ServerResponse); + + const finalResponse = new NextResponse(body); + next_response.headers.forEach((value, key) => { + finalResponse.headers.set(key, value); + }); + + return finalResponse; +} diff --git a/apps/backend/src/idp/idp.ts b/apps/backend/src/idp/idp.ts index cefe47a41..08a021350 100644 --- a/apps/backend/src/idp/idp.ts +++ b/apps/backend/src/idp/idp.ts @@ -1,32 +1,238 @@ -import { getEnvVariable } from '@stackframe/stack-shared/dist/utils/env'; import Provider from 'oidc-provider'; -interface ProviderSettings { - redirectUris: string[], - clientId: string, - clientSecret: string, +import { Configuration } from 'oidc-provider'; + +import QuickLRU from 'quick-lru'; + +interface StorageValue { + consumed?: number, + uid: string, + grantId?: string, + userCode?: string, + [key: string]: any, // Add index signature to allow arbitrary string keys } -function getProvider(providerSettings: ProviderSettings) { +let storage = new QuickLRU({ maxSize: 1000 }); +function grantKeyFor(id: string): string { + return `grant:${id}`; +} - const provider = new Provider(getEnvVariable('STACK_BASE_URL'), { - clients: [ - { - client_id: providerSettings.clientId, - client_secret: providerSettings.clientSecret, - redirect_uris: providerSettings.redirectUris, - }, - ], - }); +function sessionUidKeyFor(id: string): string { + return `sessionUid:${id}`; +} + +function userCodeKeyFor(userCode: string): string { + return `userCode:${userCode}`; +} + +const grantable = new Set([ + 'AccessToken', + 'AuthorizationCode', + 'RefreshToken', + 'DeviceCode', + 'BackchannelAuthenticationRequest', +]); + +class MemoryAdapter { + private model: string; + + constructor(model: string) { + console.log('MemoryAdapter constructor', model); + this.model = model; + } + + key(id: string): string { + return `${this.model}:${id}`; + } + + async destroy(id: string): Promise { + console.log('MemoryAdapter destroy', id); + const key = this.key(id); + storage.delete(key); + } + async consume(id: string): Promise { + console.log('MemoryAdapter consume', id); + const value = storage.get(this.key(id)) as StorageValue; + value.consumed = Math.floor(Date.now() / 1000); + } - return provider; + async find(id: string): Promise { + console.log('MemoryAdapter find', id); + return storage.get(this.key(id)) as StorageValue | undefined; + } + + async findByUid(uid: string): Promise { + console.log('MemoryAdapter findByUid', uid); + const id = storage.get(sessionUidKeyFor(uid)) as string; + return await this.find(id); + } + + async findByUserCode(userCode: string): Promise { + console.log('MemoryAdapter findByUserCode', userCode); + const id = storage.get(userCodeKeyFor(userCode)) as string; + return await this.find(id); + } + + async upsert(id: string, payload: StorageValue, expiresIn: number): Promise { + const key = this.key(id); + console.log('MemoryAdapter upsert', id, payload, expiresIn); + if (this.model === 'Session') { + storage.set(sessionUidKeyFor(payload.uid), id, { maxAge: expiresIn * 1000 }); + } + + const { grantId, userCode } = payload; + if (grantable.has(this.model) && grantId) { + const grantKey = grantKeyFor(grantId); + const grant = storage.get(grantKey) as string[]; + if (!grant) { + storage.set(grantKey, [key]); + } else { + grant.push(key); + } + } + + if (userCode) { + storage.set(userCodeKeyFor(userCode), id, { maxAge: expiresIn * 1000 }); + } + + storage.set(key, payload, { maxAge: expiresIn * 1000 }); + } + + + async revokeByGrantId(grantId: string): Promise { + const grantKey = grantKeyFor(grantId); + const grant = storage.get(grantKey) as string[]; + if (grant) { + grant.forEach((token: string) => storage.delete(token)); + storage.delete(grantKey); + } + } } -const idp = getProvider({ - redirectUris: ['http://localhost:8102/api/v1/auth/oauth/callback/github'], - clientId: 'github', - clientSecret: 'MOCK-SERVER-SECRET', +const port = Number.parseInt(process.env.PORT || "8117"); + +const mockedProviders = [ + "stack-auth", +]; + +const configuration: Configuration = { + adapter: MemoryAdapter, + clients: mockedProviders.map((providerId) => ({ + client_id: "client-id", + client_secret: "test-client-secret", + redirect_uris: [ + `http://localhost:8116/api/auth/callback/stack-auth`, + ], + })), + ttl: { + // we make sessions short so it asks us for our login again after a minute, instead of automatically logging us in with the already-logged-in session + Session: 60, + }, + features: { + devInteractions: { + enabled: false, + }, + }, + + async loadExistingGrant(ctx: any) { + const grantId = ctx.oidc.result?.consent?.grantId + || ctx.oidc.session!.grantIdFor(ctx.oidc.client!.clientId); + + if (grantId) { + // keep grant expiry aligned with session expiry + // to prevent consent prompt being requested when grant expires + const grant = await ctx.oidc.provider.Grant.find(grantId); + + // this aligns the Grant ttl with that of the current session + // if the same Grant is used for multiple sessions, or is set + // to never expire, you probably do not want this in your code + if (ctx.oidc.account && grant.exp < ctx.oidc.session!.exp) { + grant.exp = ctx.oidc.session!.exp; + + await grant.save(); + } + + if (true /*HACK*/) { + const grant = new ctx.oidc.provider.Grant({ + clientId: ctx.oidc.client!.clientId, + accountId: ctx.oidc.session!.accountId, + }); + + grant.addOIDCScope('openid email profile'); + grant.addOIDCClaims(['first_name']); + grant.addResourceScope('urn:example:resource-indicator', 'api:read api:write'); + await grant.save(); + return grant; + } + } + }, + + interactions: { + // THIS IS WHERE WE REDIRECT TO PRIMARY AUTH SERVER + // TODO: do we need to sanitize the parameter? + url: (ctx, interaction) => `/interaction/${encodeURIComponent(interaction.uid)}`, + + }, + + + async findAccount(ctx, id) { + return { + accountId: id, + claims: async (use, scope, claims, rejected) => { + return { + sub: id, + email: "test@example.com", + email_verified: true, + name: "Test User" + }; + } + }; + }, +}; + +// TODO: add stateful session management + + +const oidc = new Provider(`http://localhost:${port}`, configuration); + + +oidc.use(async (ctx, next) => { + console.log('oidc.use', ctx.path); + if (ctx.method === 'GET' && /^\/interaction\/[^/]+\/login$/.test(ctx.path)) { + console.log('oidc.use login', ctx.path); + ctx.body = 'OIDC Mock Server'; + const uid = ctx.path.split('/')[2]; + + + const grant = new oidc.Grant({ + accountId: "lmao_id", + clientId: "client-id", + }); + + const grantId = await grant.save(); + + + const result = { + login: { + accountId: "lmao_id", + }, + consent: { + grantId, + }, + }; + + return await oidc.interactionFinished(ctx.req, ctx.res, result); + } + if (ctx.method === 'GET' && /^\/interaction\/[^/]+$/.test(ctx.path)) { + console.log('oidc.use interaction', ctx.path); + const uid = ctx.path.split('/')[2]; + return ctx.redirect(`http://localhost:8103/handler/sign-in?after_auth_return_to=${encodeURIComponent(`http://localhost:8117/interaction/${encodeURIComponent(uid)}/login`)}`); + } + await next(); }); + +export { oidc }; + diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4431fbbfa..3a49ff77b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -164,6 +164,9 @@ importers: nodemailer: specifier: ^6.9.10 version: 6.9.13 + oidc-provider: + specifier: ^8.5.1 + version: 8.5.1 openid-client: specifier: ^5.6.4 version: 5.6.5 @@ -176,6 +179,9 @@ importers: posthog-node: specifier: ^4.1.0 version: 4.1.0 + quick-lru: + specifier: ^7.0.0 + version: 7.0.0 react: specifier: ^18.2 version: 18.3.1 @@ -207,6 +213,9 @@ importers: '@types/nodemailer': specifier: ^6.4.14 version: 6.4.15 + '@types/oidc-provider': + specifier: ^8.5.1 + version: 8.5.1 '@types/react': specifier: ^18.2.66 version: 18.3.3 @@ -11213,7 +11222,7 @@ snapshots: dependencies: '@mdx-js/mdx': 3.0.1 source-map: 0.7.4 - webpack: 5.92.0(@swc/core@1.3.101(@swc/helpers@0.5.13))(esbuild@0.21.5) + webpack: 5.92.0(@swc/core@1.3.101(@swc/helpers@0.5.13))(esbuild@0.19.11) transitivePeerDependencies: - supports-color @@ -13220,7 +13229,7 @@ snapshots: rollup: 2.78.0 stacktrace-parser: 0.1.10 optionalDependencies: - webpack: 5.92.0(@swc/core@1.3.101(@swc/helpers@0.5.13))(esbuild@0.21.5) + webpack: 5.92.0(@swc/core@1.3.101(@swc/helpers@0.5.13))(esbuild@0.19.11) transitivePeerDependencies: - encoding - supports-color @@ -13243,7 +13252,7 @@ snapshots: rollup: 2.78.0 stacktrace-parser: 0.1.10 optionalDependencies: - webpack: 5.92.0(@swc/core@1.3.101(@swc/helpers@0.5.13))(esbuild@0.21.5) + webpack: 5.92.0(@swc/core@1.3.101(@swc/helpers@0.5.13))(esbuild@0.19.11) transitivePeerDependencies: - encoding - supports-color @@ -13779,7 +13788,7 @@ snapshots: dependencies: '@types/node': 20.14.2 tapable: 2.2.1 - webpack: 5.92.0(@swc/core@1.3.101(@swc/helpers@0.5.13))(esbuild@0.21.5) + webpack: 5.92.0(@swc/core@1.3.101(@swc/helpers@0.5.13))(esbuild@0.19.11) transitivePeerDependencies: - '@swc/core' - esbuild @@ -19782,14 +19791,14 @@ snapshots: term-size@2.2.1: {} - terser-webpack-plugin@5.3.10(@swc/core@1.3.101(@swc/helpers@0.5.13))(esbuild@0.21.5)(webpack@5.92.0(@swc/core@1.3.101(@swc/helpers@0.5.13))(esbuild@0.21.5)): + terser-webpack-plugin@5.3.10(@swc/core@1.3.101(@swc/helpers@0.5.13))(esbuild@0.21.5)(webpack@5.92.0(@swc/core@1.3.101(@swc/helpers@0.5.13))(esbuild@0.19.11)): dependencies: '@jridgewell/trace-mapping': 0.3.25 jest-worker: 27.5.1 schema-utils: 3.3.0 serialize-javascript: 6.0.2 terser: 5.31.1 - webpack: 5.92.0(@swc/core@1.3.101(@swc/helpers@0.5.13))(esbuild@0.21.5) + webpack: 5.92.0(@swc/core@1.3.101(@swc/helpers@0.5.13))(esbuild@0.19.11) optionalDependencies: '@swc/core': 1.3.101(@swc/helpers@0.5.13) esbuild: 0.21.5 @@ -20386,7 +20395,7 @@ snapshots: webpack-sources@3.2.3: {} - webpack@5.92.0(@swc/core@1.3.101(@swc/helpers@0.5.13))(esbuild@0.21.5): + webpack@5.92.0(@swc/core@1.3.101(@swc/helpers@0.5.13))(esbuild@0.19.11): dependencies: '@types/eslint-scope': 3.7.7 '@types/estree': 1.0.5 @@ -20409,7 +20418,7 @@ snapshots: neo-async: 2.6.2 schema-utils: 3.3.0 tapable: 2.2.1 - terser-webpack-plugin: 5.3.10(@swc/core@1.3.101(@swc/helpers@0.5.13))(esbuild@0.21.5)(webpack@5.92.0(@swc/core@1.3.101(@swc/helpers@0.5.13))(esbuild@0.21.5)) + terser-webpack-plugin: 5.3.10(@swc/core@1.3.101(@swc/helpers@0.5.13))(esbuild@0.21.5)(webpack@5.92.0(@swc/core@1.3.101(@swc/helpers@0.5.13))(esbuild@0.19.11)) watchpack: 2.4.1 webpack-sources: 3.2.3 transitivePeerDependencies: