From 928a631bcc30778daefaad395d17e7c878ed2a15 Mon Sep 17 00:00:00 2001 From: Gao Sun Date: Sun, 4 Jul 2021 15:01:02 +0800 Subject: [PATCH] api: add sign in / consent --- packages/core/commitlint.config.js | 9 +++- packages/core/package.json | 1 + packages/core/src/consts.ts | 9 +++- packages/core/src/init/oidc.ts | 22 ++++++-- packages/core/src/init/router.ts | 7 ++- packages/core/src/proxies/ui.ts | 10 ++++ packages/core/src/routes/sign-in.ts | 81 ++++++++++++++++++++++++----- packages/core/src/routes/ui.ts | 20 ------- packages/core/yarn.lock | 67 +++++++++++++++++++++++- 9 files changed, 183 insertions(+), 43 deletions(-) create mode 100644 packages/core/src/proxies/ui.ts delete mode 100644 packages/core/src/routes/ui.ts diff --git a/packages/core/commitlint.config.js b/packages/core/commitlint.config.js index 28fe5c5bf..e90a52716 100644 --- a/packages/core/commitlint.config.js +++ b/packages/core/commitlint.config.js @@ -1 +1,8 @@ -module.exports = {extends: ['@commitlint/config-conventional']} +const { rules } = require('@commitlint/config-conventional'); + +module.exports = { + extends: ['@commitlint/config-conventional'], + rules: { + 'type-enum': [2, 'always', [...rules['type-enum'][2], 'api']], + }, +}; diff --git a/packages/core/package.json b/packages/core/package.json index bef60d062..bc8ea1366 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -21,6 +21,7 @@ "koa-body": "^4.2.0", "koa-logger": "^3.2.1", "koa-mount": "^4.0.0", + "koa-proxies": "^0.12.1", "koa-router": "^10.0.0", "module-alias": "^2.2.2", "oidc-provider": "^7.4.1", diff --git a/packages/core/src/consts.ts b/packages/core/src/consts.ts index f0c46b673..c3836e33b 100644 --- a/packages/core/src/consts.ts +++ b/packages/core/src/consts.ts @@ -1,3 +1,10 @@ import { assertEnv } from '@/utils/env'; -export const signInRoute = assertEnv('UI_SIGN_IN_ROUTE'); +const signIn = assertEnv('UI_SIGN_IN_ROUTE'); + +export const routes = Object.freeze({ + signIn: { + credentials: signIn, + consent: signIn + '/consent', + }, +}); diff --git a/packages/core/src/init/oidc.ts b/packages/core/src/init/oidc.ts index c499ea497..2afc3f0ed 100644 --- a/packages/core/src/init/oidc.ts +++ b/packages/core/src/init/oidc.ts @@ -7,22 +7,29 @@ import postgresAdapter from '@/oidc/adapter'; import { fromKeyLike } from 'jose/jwk/from_key_like'; import { getEnv } from '@/utils/env'; import { findUserById } from '@/queries/user'; -import { signInRoute } from '@/consts'; +import { routes } from '@/consts'; export default async function initOidc(app: Koa, port: number): Promise { const privateKey = crypto.createPrivateKey( Buffer.from(getEnv('OIDC_PROVIDER_PRIVATE_KEY_BASE64'), 'base64') ); const keys = [await fromKeyLike(privateKey)]; + const cookieConfig = Object.freeze({ + sameSite: 'lax', + path: '/', + signed: true, + } as const); const oidc = new Provider(`http://localhost:${port}/oidc`, { adapter: postgresAdapter, renderError: (ctx, out, error) => { - console.log(error); + console.log('OIDC error', error); }, cookies: { // V2: Rotate this when necessary // https://github.com/panva/node-oidc-provider/blob/main/docs/README.md#cookieskeys keys: ['LOGTOSEKRIT1'], + long: cookieConfig, + short: cookieConfig, }, jwks: { keys, @@ -41,7 +48,16 @@ export default async function initOidc(app: Koa, port: number): Promise signInRoute, + url: (_, interaction) => { + switch (interaction.prompt.name) { + case 'login': + return routes.signIn.credentials; + case 'consent': + return routes.signIn.consent; + default: + throw new Error(`Prompt not supported: ${interaction.prompt.name}`); + } + }, }, clientBasedCORS: (_, origin) => { console.log('origin', origin); diff --git a/packages/core/src/init/router.ts b/packages/core/src/init/router.ts index 379548630..1fa079876 100644 --- a/packages/core/src/init/router.ts +++ b/packages/core/src/init/router.ts @@ -2,19 +2,18 @@ import Koa from 'koa'; import Router from 'koa-router'; import { Provider } from 'oidc-provider'; import createSignInRoutes from '@/routes/sign-in'; -import createUIRoutes from '@/routes/ui'; +import createUIProxy from '@/proxies/ui'; const createRouter = (provider: Provider): Router => { const router = new Router(); - router.use('/api', createSignInRoutes()); - router.use(createUIRoutes(provider)); + router.use('/api', createSignInRoutes(provider)); return router; }; export default function initRouter(app: Koa, provider: Provider): Router { const router = createRouter(provider); - app.use(router.routes()).use(router.allowedMethods()); + app.use(router.routes()).use(createUIProxy()).use(router.allowedMethods()); return router; } diff --git a/packages/core/src/proxies/ui.ts b/packages/core/src/proxies/ui.ts new file mode 100644 index 000000000..efb2816c9 --- /dev/null +++ b/packages/core/src/proxies/ui.ts @@ -0,0 +1,10 @@ +import proxy from 'koa-proxies'; + +// CAUTION: this is for testing only +export default function createUIProxy() { + return proxy(/^\/(?!api|oidc).*$/, { + target: 'http://localhost:3000', + changeOrigin: true, + logs: true, + }); +} diff --git a/packages/core/src/routes/sign-in.ts b/packages/core/src/routes/sign-in.ts index 87312973b..a6eb18815 100644 --- a/packages/core/src/routes/sign-in.ts +++ b/packages/core/src/routes/sign-in.ts @@ -4,26 +4,81 @@ import koaBody from 'koa-body'; import { object, string } from 'zod'; import { encryptPassword } from '@/utils/password'; import { findUserById } from '@/queries/user'; +import { Provider } from 'oidc-provider'; +import { conditional } from '@logto/essentials'; -export default function createSignInRoutes() { +export default function createSignInRoutes(provider: Provider) { const router = new Router(); router.post('/sign-in', koaBody(), async (ctx) => { - const SignInBody = object({ - id: string().min(1), - password: string().min(1), - }); - const { id, password } = SignInBody.parse(ctx.request.body); - const { passwordEncrypted, passwordEncryptionMethod, passwordEncryptionSalt } = - await findUserById(id); + const { + prompt: { name }, + } = await provider.interactionDetails(ctx.req, ctx.res); - assert(passwordEncrypted && passwordEncryptionMethod && passwordEncryptionSalt); - assert( - encryptPassword(id, password, passwordEncryptionSalt, passwordEncryptionMethod) === - passwordEncrypted + if (name === 'login') { + const SignInBody = object({ + id: string().min(1), + password: string().min(1), + }); + const { id, password } = SignInBody.parse(ctx.request.body); + + const { passwordEncrypted, passwordEncryptionMethod, passwordEncryptionSalt } = + await findUserById(id); + + assert(passwordEncrypted && passwordEncryptionMethod && passwordEncryptionSalt); + assert( + encryptPassword(id, password, passwordEncryptionSalt, passwordEncryptionMethod) === + passwordEncrypted + ); + + const redirectTo = await provider.interactionResult( + ctx.req, + ctx.res, + { + login: { accountId: id }, + }, + { mergeWithLastSubmission: false } + ); + ctx.body = { redirectTo }; + } else if (name === 'consent') { + ctx.body = { redirectTo: ctx.request.origin + '/sign-in/consent' }; + } else { + throw new Error(`Prompt not supported: ${name}`); + } + }); + + router.post('/sign-in/consent', async (ctx) => { + const { session, grantId, params, prompt } = await provider.interactionDetails( + ctx.req, + ctx.res ); - ctx.status = 204; + assert(session, 'Session not found'); + const { accountId } = session; + const grant = + conditional(grantId && (await provider.Grant.find(grantId))) ?? + new provider.Grant({ accountId, clientId: String(params.client_id) }); + + // V2: fulfill missing claims / resources + const PromptDetailsBody = object({ + missingOIDCScope: string().array().optional(), + }); + const { missingOIDCScope } = PromptDetailsBody.parse(prompt.details); + + if (missingOIDCScope) { + grant.addOIDCScope(missingOIDCScope.join(' ')); + } + + const finalGrantId = await grant.save(); + + // V2: configure consent + const redirectTo = await provider.interactionResult( + ctx.req, + ctx.res, + { consent: { grantId: finalGrantId } }, + { mergeWithLastSubmission: true } + ); + ctx.body = { redirectTo }; }); return router.routes(); diff --git a/packages/core/src/routes/ui.ts b/packages/core/src/routes/ui.ts deleted file mode 100644 index 29a490b25..000000000 --- a/packages/core/src/routes/ui.ts +++ /dev/null @@ -1,20 +0,0 @@ -import got from 'got'; -import Router from 'koa-router'; -import { promisify } from 'util'; -import stream from 'stream'; -import { signInRoute } from '@/consts'; -import { getEnv } from '@/utils/env'; -import { Provider } from 'oidc-provider'; - -export default function createUIRoutes(provider: Provider) { - const pipeline = promisify(stream.pipeline); - const router = new Router(); - - router.get(new RegExp(`^${signInRoute}(?:/|$)`), async (ctx) => { - const details = await provider.interactionDetails(ctx.req, ctx.res); - console.log('details', details); - // CAUTION: this is for dev purpose only, add a switch if needed - await pipeline(got.stream.get(getEnv('UI_PLAYGROUND_URL')), ctx.res); - }); - return router.routes(); -} diff --git a/packages/core/yarn.lock b/packages/core/yarn.lock index e2e35032c..a322fc178 100644 --- a/packages/core/yarn.lock +++ b/packages/core/yarn.lock @@ -2180,6 +2180,11 @@ event-stream@=3.3.4: stream-combiner "~0.0.4" through "~2.3.1" +eventemitter3@^4.0.0: + version "4.0.7" + resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-4.0.7.tgz#2de9b68f6528d5644ef5c59526a1b4a07306169f" + integrity sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw== + execa@^5.0.0: version "5.1.1" resolved "https://registry.yarnpkg.com/execa/-/execa-5.1.1.tgz#f80ad9cbf4298f7bd1d4c9555c21e93741c411dd" @@ -2386,6 +2391,11 @@ flatted@^3.1.0: resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.1.1.tgz#c4b489e80096d9df1dfc97c79871aea7c617c469" integrity sha512-zAoAQiudy+r5SvnSw3KJy5os/oRJYHzrzja/tBDqrZtNhUw8bt6y8OBzMWcjWr+8liV8Eb6yOhw8WZ7VFZ5ZzA== +follow-redirects@^1.0.0: + version "1.14.1" + resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.14.1.tgz#d9114ded0a1cfdd334e164e6662ad02bfd91ff43" + integrity sha512-HWqDgT7ZEkqRzBvc2s64vSZ/hfOceEol3ac/7tKwzuvEyWx3/4UegXh5oBOIotkGsObyk3xznnSRVADBgWSQVg== + for-in@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/for-in/-/for-in-1.0.2.tgz#81068d295a8142ec0ac726c6e2200c30fb6d5e80" @@ -2755,6 +2765,23 @@ http-errors@^1.6.3, http-errors@^1.7.3: statuses ">= 1.5.0 < 2" toidentifier "1.0.0" +http-errors@~1.4.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-1.4.0.tgz#6c0242dea6b3df7afda153c71089b31c6e82aabf" + integrity sha1-bAJC3qaz33r9oVPHEImzHG6Cqr8= + dependencies: + inherits "2.0.1" + statuses ">= 1.2.1 < 2" + +http-proxy@^1.18.1: + version "1.18.1" + resolved "https://registry.yarnpkg.com/http-proxy/-/http-proxy-1.18.1.tgz#401541f0534884bbf95260334e72f88ee3976549" + integrity sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ== + dependencies: + eventemitter3 "^4.0.0" + follow-redirects "^1.0.0" + requires-port "^1.0.0" + http2-wrapper@^1.0.0-beta.5.2: version "1.0.3" resolved "https://registry.yarnpkg.com/http2-wrapper/-/http2-wrapper-1.0.3.tgz#b8f55e0c1f25d4ebd08b3b0c2c079f9590800b3d" @@ -2854,6 +2881,11 @@ inherits@2, inherits@2.0.4, inherits@^2.0.3, inherits@^2.0.4: resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== +inherits@2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.1.tgz#b17d08d326b4423e568eff719f91b0b1cbdf69f1" + integrity sha1-sX0I0ya0Qj5Wjv9xn5GwscvfafE= + ini@2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/ini/-/ini-2.0.0.tgz#e5fd556ecdd5726be978fa1001862eacb0a94bc5" @@ -3219,6 +3251,11 @@ is-yarn-global@^0.3.0: resolved "https://registry.yarnpkg.com/is-yarn-global/-/is-yarn-global-0.3.0.tgz#d502d3382590ea3004893746754c89139973e232" integrity sha512-VjSeb/lHmkoyd8ryPVIKvOCn4D1koMqY+vqyjjUfc3xyKtP4dYOxM44sZrnqQSzSds3xyOrUTLTC9LVCVgLngw== +isarray@0.0.1: + version "0.0.1" + resolved "https://registry.yarnpkg.com/isarray/-/isarray-0.0.1.tgz#8a18acfca9a8f4177e09abfc6038939b05d1eedf" + integrity sha1-ihis/Kmo9Bd+Cav8YDiTmwXR7t8= + isarray@1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/isarray/-/isarray-1.0.0.tgz#bb935d48582cba168c06834957a54a3e07124f11" @@ -3449,6 +3486,14 @@ koa-mount@^4.0.0: debug "^4.0.1" koa-compose "^4.1.0" +koa-proxies@^0.12.1: + version "0.12.1" + resolved "https://registry.yarnpkg.com/koa-proxies/-/koa-proxies-0.12.1.tgz#c0c5f2332b791f095b5d0d77cea15237514acebd" + integrity sha512-qCOGY7Qoe/Ewn2VskP9TdLMZffmsv8JUBWllNlmTJmgl1059nxt5jl7QBWNniqx2BthVSU5TIBuhUULA5d6t+A== + dependencies: + http-proxy "^1.18.1" + path-match "^1.2.4" + koa-router@^10.0.0: version "10.0.0" resolved "https://registry.yarnpkg.com/koa-router/-/koa-router-10.0.0.tgz#7bc76a031085731e61fc92c1683687b2f44de6a4" @@ -4205,11 +4250,26 @@ path-key@^3.0.0, path-key@^3.1.0: resolved "https://registry.yarnpkg.com/path-key/-/path-key-3.1.1.tgz#581f6ade658cbba65a0d3380de7753295054f375" integrity sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q== +path-match@^1.2.4: + version "1.2.4" + resolved "https://registry.yarnpkg.com/path-match/-/path-match-1.2.4.tgz#a62747f3c7e0c2514762697f24443585b09100ea" + integrity sha1-pidH88fgwlFHYml/JEQ1hbCRAOo= + dependencies: + http-errors "~1.4.0" + path-to-regexp "^1.0.0" + path-parse@^1.0.6: version "1.0.7" resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.7.tgz#fbc114b60ca42b30d9daf5858e4bd68bbedb6735" integrity sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw== +path-to-regexp@^1.0.0: + version "1.8.0" + resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-1.8.0.tgz#887b3ba9d84393e87a0a0b9f4cb756198b53548a" + integrity sha512-n43JRhlUKUAlibEJhPeir1ncUID16QnEjNpwzNdO3Lm4ywrBpBZ5oLD0I6br9evr1Y9JTqwRtAh7JLoOzAQdVA== + dependencies: + isarray "0.0.1" + path-to-regexp@^6.1.0: version "6.2.0" resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-6.2.0.tgz#f7b3803336104c346889adece614669230645f38" @@ -4674,6 +4734,11 @@ require-from-string@^2.0.2: resolved "https://registry.yarnpkg.com/require-from-string/-/require-from-string-2.0.2.tgz#89a7fdd938261267318eafe14f9c32e598c36909" integrity sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw== +requires-port@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/requires-port/-/requires-port-1.0.0.tgz#925d2601d39ac485e091cf0da5c6e694dc3dcaff" + integrity sha1-kl0mAdOaxIXgkc8NpcbmlNw9yv8= + reserved-words@^0.1.2: version "0.1.2" resolved "https://registry.yarnpkg.com/reserved-words/-/reserved-words-0.1.2.tgz#00a0940f98cd501aeaaac316411d9adc52b31ab1" @@ -5169,7 +5234,7 @@ static-extend@^0.1.1: define-property "^0.2.5" object-copy "^0.1.0" -"statuses@>= 1.5.0 < 2", statuses@^1.5.0: +"statuses@>= 1.2.1 < 2", "statuses@>= 1.5.0 < 2", statuses@^1.5.0: version "1.5.0" resolved "https://registry.yarnpkg.com/statuses/-/statuses-1.5.0.tgz#161c7dac177659fd9811f43771fa99381478628c" integrity sha1-Fhx9rBd2Wf2YEfQ3cfqZOBR4Yow=