From 7c84df0b35247d4040afeaf4161b99066aac3289 Mon Sep 17 00:00:00 2001 From: takoring Date: Fri, 10 Jan 2025 11:43:48 +0900 Subject: [PATCH 01/25] feat(nodejs): auth oidc --- docker-compose.example-nodejs.mongo.yml | 42 ++--- example/nodejs/package.json | 5 +- example/nodejs/src/config/development.ts | 7 + example/nodejs/src/config/index.ts | 1 + example/nodejs/src/config/local.ts | 9 +- example/nodejs/src/config/production.ts | 7 + example/nodejs/src/controllers/auth.ts | 49 ++++++ example/nodejs/src/controllers/authconfigs.ts | 7 + package-lock.json | 162 +++++++++++++++++- packages/nodejs/package.json | 1 + packages/nodejs/src/constants.ts | 29 ++-- packages/nodejs/src/domains/auth/index.ts | 1 + packages/nodejs/src/domains/auth/oidc.ts | 110 ++++++++++++ packages/nodejs/src/errors.ts | 8 + packages/nodejs/src/helpers/cookies.ts | 13 ++ packages/nodejs/src/openapi/auth.yaml | 15 ++ packages/nodejs/src/openapi/authconfigs.yaml | 1 + 17 files changed, 429 insertions(+), 38 deletions(-) create mode 100644 packages/nodejs/src/domains/auth/oidc.ts diff --git a/docker-compose.example-nodejs.mongo.yml b/docker-compose.example-nodejs.mongo.yml index 3161bbb51..6f69b1aaf 100644 --- a/docker-compose.example-nodejs.mongo.yml +++ b/docker-compose.example-nodejs.mongo.yml @@ -2,27 +2,27 @@ version: '3.8' services: - example: - build: - context: '.' - dockerfile: Dockerfile.example-nodejs - restart: always - depends_on: - - mongo - ports: - - 3000:3000 - - 9229:9229 # node-inspector - environment: - - MODE=mongo - env_file: - - $PWD/example/nodejs/.env - volumes: - - $PWD/example/nodejs/package.json:/viron/example/nodejs/package.json - - $PWD/example/nodejs/src:/viron/example/nodejs/src - - $PWD/example/nodejs/cert:/viron/example/nodejs/cert - - $PWD/packages/nodejs:/viron/packages/nodejs - - $PWD/packages/linter:/viron/packages/linter - command: npm run dev + # example: + # build: + # context: '.' + # dockerfile: Dockerfile.example-nodejs + # restart: always + # depends_on: + # - mongo + # ports: + # - 3000:3000 + # - 9229:9229 # node-inspector + # environment: + # - MODE=mongo + # env_file: + # - $PWD/example/nodejs/.env + # volumes: + # - $PWD/example/nodejs/package.json:/viron/example/nodejs/package.json + # - $PWD/example/nodejs/src:/viron/example/nodejs/src + # - $PWD/example/nodejs/cert:/viron/example/nodejs/cert + # - $PWD/packages/nodejs:/viron/packages/nodejs + # - $PWD/packages/linter:/viron/packages/linter + # command: npm run dev mongo: extends: diff --git a/example/nodejs/package.json b/example/nodejs/package.json index 4626e9772..fc770c548 100644 --- a/example/nodejs/package.json +++ b/example/nodejs/package.json @@ -10,6 +10,8 @@ "compression": "^1.7.4", "cookie-parser": "^1.4.5", "cors": "^2.8.5", + "cross-env": "^7.0.3", + "dotenv-cli": "^8.0.0", "exegesis-express": "^2.0.1", "express": "^4.19.2", "i18next": "^21.6.7", @@ -20,6 +22,7 @@ "multer": "^1.4.3", "multer-s3": "^3.0.1", "mysql2": "^2.2.5", + "openid-client": "^6.1.7", "pino": "^7.6.4", "pino-http": "^6.6.0", "sequelize": "^6.5.0", @@ -68,7 +71,7 @@ "docker-compose:connect:mongo": "mongo -u root --authenticationDatabase admin mongodb://127.0.0.1:27017", "docker-compose:up:mysql": "docker-compose -f ./docker-compose.mysql.yml up", "docker-compose:up:mongo": "docker-compose -f ./docker-compose.mongo.yml up", - "dev": "DEBUG=* ts-node-dev --watch src/openapi --debug --inspect=0.0.0.0:9229 --poll -- src/server.ts", + "dev": "cross-env $(cat .env | xargs) DEBUG=* ts-node-dev --watch src/openapi --debug --inspect=0.0.0.0:9229 --poll -- src/server.ts", "lint": "npm run lint:ts", "lint:ts": "eslint \"{src,__tests__}/**/*.{ts,tsx}\"", "lint:ts:fix": "eslint --fix \"{src,__tests__}/**/*.{ts,tsx}\"", diff --git a/example/nodejs/src/config/development.ts b/example/nodejs/src/config/development.ts index 34f404757..2aacf2f64 100644 --- a/example/nodejs/src/config/development.ts +++ b/example/nodejs/src/config/development.ts @@ -47,6 +47,13 @@ export const get = (): Config => { additionalScopes: [], userHostedDomains: ['cam-inc.co.jp', 'cyberagent.co.jp'], }, + oidc: { + server: 'https://federation.perman.jp', + clientId: process.env.OIDC_CLIENT_ID ?? '', + clientSecret: process.env.OIDC_CLIENT_SECRET ?? '', + tokenEndpoint: 'https://federation.perman.jp/api/v1/oidc/token', + callbackUrl: 'https://example.viron.work:3000/oidc/callback', + }, }, aws: { s3: { diff --git a/example/nodejs/src/config/index.ts b/example/nodejs/src/config/index.ts index fa84fd2b9..092ba84df 100644 --- a/example/nodejs/src/config/index.ts +++ b/example/nodejs/src/config/index.ts @@ -48,6 +48,7 @@ export interface Config { auth: { jwt: domainsAuth.JwtConfig; googleOAuth2: domainsAuth.GoogleOAuthConfig; + oidc: domainsAuth.OidcClientConfig; }; aws: AWSConfig; oas: OasConfig; diff --git a/example/nodejs/src/config/local.ts b/example/nodejs/src/config/local.ts index a019293d8..bacddf008 100644 --- a/example/nodejs/src/config/local.ts +++ b/example/nodejs/src/config/local.ts @@ -8,7 +8,7 @@ import { Mode, MODE } from '../constants'; export const get = (mode: Mode): Config => { const mongo: MongoConfig = { type: 'mongo', - openUri: 'mongodb://mongo:27017', + openUri: 'mongodb://0.0.0.0:27017', connectOptions: { // MongoDB Options dbName: 'viron_example', @@ -58,6 +58,13 @@ export const get = (mode: Mode): Config => { additionalScopes: [], userHostedDomains: ['cam-inc.co.jp', 'cyberagent.co.jp'], }, + oidc: { + server: 'https://federation.perman.jp', + clientId: process.env.OIDC_CLIENT_ID ?? '', + clientSecret: process.env.OIDC_CLIENT_SECRET ?? '', + tokenEndpoint: 'https://federation.perman.jp/api/v1/oidc/token', + callbackUrl: 'https://example.viron.work:3000/oidc/callback', + }, }, aws: { s3: { diff --git a/example/nodejs/src/config/production.ts b/example/nodejs/src/config/production.ts index b28aeaabc..5942682ed 100644 --- a/example/nodejs/src/config/production.ts +++ b/example/nodejs/src/config/production.ts @@ -44,6 +44,13 @@ export const get = (): Config => { additionalScopes: [], userHostedDomains: ['gmail.com', 'cam-inc.co.jp', 'cyberagent.co.jp'], }, + oidc: { + server: 'https://federation.perman.jp', + clientId: process.env.OIDC_CLIENT_ID ?? '', + clientSecret: process.env.OIDC_CLIENT_SECRET ?? '', + tokenEndpoint: 'https://federation.perman.jp/api/v1/oidc/token', + callbackUrl: 'https://example.viron.work:3000/oidc/callback', + }, }, aws: { s3: { diff --git a/example/nodejs/src/controllers/auth.ts b/example/nodejs/src/controllers/auth.ts index bfd22b8bb..b81ae5853 100644 --- a/example/nodejs/src/controllers/auth.ts +++ b/example/nodejs/src/controllers/auth.ts @@ -2,6 +2,7 @@ import { domainsAuth, genAuthorizationCookie, genOAuthStateCookie, + genOidcStateCookie, mismatchState, COOKIE_KEY, HTTP_HEADER, @@ -28,6 +29,54 @@ export const signinEmail = async (context: RouteContext): Promise => { context.res.status(204).end(); }; +// OIDCの認証画面へリダイレクト +export const oidcAuthorization = async ( + context: RouteContext +): Promise => { + const { redirectUri } = context.params.query; + const state = domainsAuth.genOidcState(); + const client = await domainsAuth.getOidcClient( + redirectUri, + ctx.config.auth.oidc + ); + const codeVerifier = await domainsAuth.genOidcCodeVerifier(); + + const authorizationUrl = await domainsAuth.getOidcAuthorizationUrl( + client, + codeVerifier + ); + + context.res.setHeader(HTTP_HEADER.SET_COOKIE, genOidcStateCookie(state)); + context.res.setHeader(HTTP_HEADER.LOCATION, authorizationUrl); + context.res.status(301).end(); +}; + +// OIDCのコールバック +export const oidcCallback = async (context: RouteContext): Promise => { + const cookieState = context.req.cookies[COOKIE_KEY.OIDC_STATE]; + const { state } = context.params.query; + + if (!cookieState || !state || cookieState !== state) { + throw mismatchState(); + } + + const client = await domainsAuth.getOidcClient('', ctx.config.auth.oidc); + const codeVerifier = await domainsAuth.genOidcCodeVerifier(); + const token = await domainsAuth.signinOidc( + client, + codeVerifier, + context.requestBody, + ctx.config.auth.oidc + ); + context.res.setHeader( + HTTP_HEADER.SET_COOKIE, + genAuthorizationCookie(token, { + maxAge: ctx.config.auth.jwt.expirationSec, + }) + ); + context.res.status(204).end(); +}; + // GoogleOAuth2の認可画面へリダイレクト export const oauth2GoogleAuthorization = async ( context: RouteContext diff --git a/example/nodejs/src/controllers/authconfigs.ts b/example/nodejs/src/controllers/authconfigs.ts index ab628b874..c4cf63c37 100644 --- a/example/nodejs/src/controllers/authconfigs.ts +++ b/example/nodejs/src/controllers/authconfigs.ts @@ -7,6 +7,7 @@ import { SIGNOUT_PATH, OAUTH2_GOOGLE_AUTHORIZATION_PATH, OAUTH2_GOOGLE_CALLBACK_PATH, + OIDC_AUTHORIZATION_PATH, } from '@viron/lib'; import { RouteContext } from '../application'; @@ -35,6 +36,12 @@ export const listVironAuthconfigs = async ( method: API_METHOD.POST, path: OAUTH2_GOOGLE_CALLBACK_PATH, }, + { + provider: AUTH_CONFIG_PROVIDER.GOOGLE, + type: AUTH_CONFIG_TYPE.OIDC, + method: API_METHOD.GET, + path: OIDC_AUTHORIZATION_PATH, + }, { provider: AUTH_CONFIG_PROVIDER.SIGNOUT, type: AUTH_CONFIG_TYPE.SIGNOUT, diff --git a/package-lock.json b/package-lock.json index 47b1f8f9b..6984eb001 100644 --- a/package-lock.json +++ b/package-lock.json @@ -36,6 +36,8 @@ "compression": "^1.7.4", "cookie-parser": "^1.4.5", "cors": "^2.8.5", + "cross-env": "^7.0.3", + "dotenv-cli": "^8.0.0", "exegesis-express": "^2.0.1", "express": "^4.19.2", "i18next": "^21.6.7", @@ -46,6 +48,7 @@ "multer": "^1.4.3", "multer-s3": "^3.0.1", "mysql2": "^2.2.5", + "openid-client": "^6.1.7", "pino": "^7.6.4", "pino-http": "^6.6.0", "sequelize": "^6.5.0", @@ -13350,6 +13353,14 @@ "node": ">=10" } }, + "node_modules/@panva/asn1.js": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@panva/asn1.js/-/asn1.js-1.0.0.tgz", + "integrity": "sha512-UdkG3mLEqXgnlKsWanWcgb6dOjUzJ+XC5f+aWw30qrtjxeNUSfKX1cd5FBzOaXQumoe9nIqeZUvrRJS03HCCtw==", + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/@parcel/bundler-default": { "version": "2.8.3", "resolved": "https://registry.npmjs.org/@parcel/bundler-default/-/bundler-default-2.8.3.tgz", @@ -27182,6 +27193,23 @@ "devOptional": true, "license": "MIT" }, + "node_modules/cross-env": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz", + "integrity": "sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==", + "dependencies": { + "cross-spawn": "^7.0.1" + }, + "bin": { + "cross-env": "src/bin/cross-env.js", + "cross-env-shell": "src/bin/cross-env-shell.js" + }, + "engines": { + "node": ">=10.14", + "npm": ">=6", + "yarn": ">=1" + } + }, "node_modules/cross-fetch": { "version": "3.1.8", "license": "MIT", @@ -27190,8 +27218,9 @@ } }, "node_modules/cross-spawn": { - "version": "7.0.3", - "license": "MIT", + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", @@ -29064,6 +29093,39 @@ "node": ">=10" } }, + "node_modules/dotenv-cli": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/dotenv-cli/-/dotenv-cli-8.0.0.tgz", + "integrity": "sha512-aLqYbK7xKOiTMIRf1lDPbI+Y+Ip/wo5k3eyp6ePysVaSqbyxjyK3dK35BTxG+rmd7djf5q2UPs4noPNH+cj0Qw==", + "dependencies": { + "cross-spawn": "^7.0.6", + "dotenv": "^16.3.0", + "dotenv-expand": "^10.0.0", + "minimist": "^1.2.6" + }, + "bin": { + "dotenv": "cli.js" + } + }, + "node_modules/dotenv-cli/node_modules/dotenv": { + "version": "16.4.7", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.7.tgz", + "integrity": "sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dotenv-cli/node_modules/dotenv-expand": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-10.0.0.tgz", + "integrity": "sha512-GopVGCpVS1UKH75VKHGuQFqS1Gusej0z4FyQkPdwjil2gNIv+LNsqBlboOzpJFZKVT95GkCyWJbBSdFEFUWI2A==", + "engines": { + "node": ">=12" + } + }, "node_modules/dotenv-expand": { "version": "5.1.0", "license": "BSD-2-Clause" @@ -39684,6 +39746,14 @@ "@sideway/pinpoint": "^2.0.0" } }, + "node_modules/jose": { + "version": "5.9.6", + "resolved": "https://registry.npmjs.org/jose/-/jose-5.9.6.tgz", + "integrity": "sha512-AMlnetc9+CV9asI19zHmrgS/WYsWUwCn2R7RzlbJWD7F9eWYUTGyBmU9o6PxngtLGOiDGPRu+Uc4fhKzbpteZQ==", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/joycon": { "version": "3.1.1", "dev": true, @@ -40990,7 +41060,6 @@ }, "node_modules/make-error": { "version": "1.3.6", - "devOptional": true, "license": "ISC" }, "node_modules/makeerror": { @@ -45609,6 +45678,14 @@ "version": "2.2.7", "license": "MIT" }, + "node_modules/oauth4webapi": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.1.4.tgz", + "integrity": "sha512-eVfN3nZNbok2s/ROifO0UAc5G8nRoLSbrcKJ09OqmucgnhXEfdIQOR4gq1eJH1rN3gV7rNw62bDEgftsgFtBEg==", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/object-assign": { "version": "4.1.1", "license": "MIT", @@ -45853,6 +45930,14 @@ "version": "1.1.2", "license": "MIT" }, + "node_modules/oidc-token-hash": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/oidc-token-hash/-/oidc-token-hash-5.0.3.tgz", + "integrity": "sha512-IF4PcGgzAr6XXSff26Sk/+P4KZFJVuHAJZj3wgO3vX2bMdNVp/QXTP3P7CEm9V1IdG8lDLY3HhiqpsE/nOwpPw==", + "engines": { + "node": "^10.13.0 || >=12.0.0" + } + }, "node_modules/on-exit-leak-free": { "version": "0.2.0", "license": "MIT" @@ -45924,6 +46009,18 @@ "opener": "bin/opener-bin.js" } }, + "node_modules/openid-client": { + "version": "6.1.7", + "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-6.1.7.tgz", + "integrity": "sha512-JfY/KvQgOutmG2P+oVNKInE7zIh+im1MQOaO7g5CtNnTWMociA563WweiEMKfR9ry9XG3K2HGvj9wEqhCQkPMg==", + "dependencies": { + "jose": "^5.9.6", + "oauth4webapi": "^3.1.4" + }, + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/opentracing": { "version": "0.14.7", "resolved": "https://registry.npmjs.org/opentracing/-/opentracing-0.14.7.tgz", @@ -58779,6 +58876,7 @@ "json-schema-traverse": "^1.0.0", "jsonwebtoken": "^9.0.0", "mongoose": "^7.3.4", + "openid-client": "^4.7.4", "path-to-regexp": "^6.2.0", "sequelize": "^6.12.5", "uuid": "^8.3.2" @@ -59902,6 +60000,20 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, + "packages/nodejs/node_modules/jose": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/jose/-/jose-2.0.7.tgz", + "integrity": "sha512-5hFWIigKqC+e/lRyQhfnirrAqUdIPMB7SJRqflJaO29dW7q5DFvH1XCSTmv6PQ6pb++0k6MJlLRoS0Wv4s38Wg==", + "dependencies": { + "@panva/asn1.js": "^1.0.0" + }, + "engines": { + "node": ">=10.13.0 < 13 || >=13.7.0" + }, + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "packages/nodejs/node_modules/js-yaml": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", @@ -59921,6 +60033,17 @@ "node": ">=12.0.0" } }, + "packages/nodejs/node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, "packages/nodejs/node_modules/mongodb": { "version": "5.6.0", "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-5.6.0.tgz", @@ -59998,6 +60121,34 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" }, + "packages/nodejs/node_modules/object-hash": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-2.2.0.tgz", + "integrity": "sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==", + "engines": { + "node": ">= 6" + } + }, + "packages/nodejs/node_modules/openid-client": { + "version": "4.7.4", + "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-4.7.4.tgz", + "integrity": "sha512-n+RURXYuR0bBZo9i0pn+CXZSyg5JYQ1nbwEwPQvLE7EcJt/vMZ2iIMjLehl5DvCN53XUoPVZs9KAE5r6d9fxsw==", + "dependencies": { + "aggregate-error": "^3.1.0", + "got": "^11.8.0", + "jose": "^2.0.5", + "lru-cache": "^6.0.0", + "make-error": "^1.3.6", + "object-hash": "^2.0.1", + "oidc-token-hash": "^5.0.1" + }, + "engines": { + "node": "^10.19.0 || >=12.0.0 < 13 || >=13.7.0 < 14 || >= 14.2.0" + }, + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "packages/nodejs/node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", @@ -60179,6 +60330,11 @@ "node": "^12.13.0 || ^14.15.0 || >=16.0.0" } }, + "packages/nodejs/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" + }, "packages/nodejs/node_modules/yargs-parser": { "version": "21.1.1", "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", diff --git a/packages/nodejs/package.json b/packages/nodejs/package.json index 0dbd6f431..4adc252e8 100644 --- a/packages/nodejs/package.json +++ b/packages/nodejs/package.json @@ -35,6 +35,7 @@ "json-schema-traverse": "^1.0.0", "jsonwebtoken": "^9.0.0", "mongoose": "^7.3.4", + "openid-client": "^4.7.4", "path-to-regexp": "^6.2.0", "sequelize": "^6.12.5", "uuid": "^8.3.2" diff --git a/packages/nodejs/src/constants.ts b/packages/nodejs/src/constants.ts index 193db55e8..df4171c78 100644 --- a/packages/nodejs/src/constants.ts +++ b/packages/nodejs/src/constants.ts @@ -5,16 +5,17 @@ export const API_METHOD = { PATCH: 'patch', DELETE: 'delete', } as const; -export type ApiMethod = typeof API_METHOD[keyof typeof API_METHOD]; +export type ApiMethod = (typeof API_METHOD)[keyof typeof API_METHOD]; export const AUTH_CONFIG_TYPE = { EMAIL: 'email', OAUTH: 'oauth', + OIDC: 'oidc', OAUTH_CALLBACK: 'oauthcallback', SIGNOUT: 'signout', } as const; export type AuthConfigType = - typeof AUTH_CONFIG_TYPE[keyof typeof AUTH_CONFIG_TYPE]; + (typeof AUTH_CONFIG_TYPE)[keyof typeof AUTH_CONFIG_TYPE]; export const AUTH_CONFIG_PROVIDER = { VIRON: 'viron', @@ -22,13 +23,13 @@ export const AUTH_CONFIG_PROVIDER = { SIGNOUT: 'signout', } as const; export type AuthConfigProvider = - typeof AUTH_CONFIG_PROVIDER[keyof typeof AUTH_CONFIG_PROVIDER]; + (typeof AUTH_CONFIG_PROVIDER)[keyof typeof AUTH_CONFIG_PROVIDER]; export const STORE_TYPE = { MYSQL: 'mysql', MONGO: 'mongo', } as const; -export type StoreType = typeof STORE_TYPE[keyof typeof STORE_TYPE]; +export type StoreType = (typeof STORE_TYPE)[keyof typeof STORE_TYPE]; export const HTTP_HEADER = { ACCESS_CONTROL_ALLOW_ORIGIN: 'access-control-allow-origin', @@ -45,7 +46,7 @@ export const HTTP_HEADER = { X_REQUESTED_WITH: 'x-requested-with', X_VIRON_AUTHTYPES_PATH: 'x-viron-authtypes-path', } as const; -export type HttpHeader = typeof HTTP_HEADER[keyof typeof HTTP_HEADER]; +export type HttpHeader = (typeof HTTP_HEADER)[keyof typeof HTTP_HEADER]; export const DEFAULT_PAGER_SIZE = 10; export const DEFAULT_PAGER_PAGE = 1; @@ -78,12 +79,13 @@ export const ADMIN_ROLE = { SUPER: 'super', VIEWER: 'viewer', } as const; -export type AdminRole = typeof ADMIN_ROLE[keyof typeof ADMIN_ROLE]; +export type AdminRole = (typeof ADMIN_ROLE)[keyof typeof ADMIN_ROLE]; export const VIRON_AUTHCONFIGS_PATH = '/viron/authconfigs'; export const EMAIL_SIGNIN_PATH = '/email/signin'; export const OAUTH2_GOOGLE_AUTHORIZATION_PATH = '/oauth2/google/authorization'; export const OAUTH2_GOOGLE_CALLBACK_PATH = '/oauth2/google/callback'; +export const OIDC_AUTHORIZATION_PATH = '/oidc/authorization'; export const SIGNOUT_PATH = '/signout'; export const PERMISSION = { @@ -92,7 +94,7 @@ export const PERMISSION = { ALL: 'all', DENY: 'deny', } as const; -export type Permission = typeof PERMISSION[keyof typeof PERMISSION]; +export type Permission = (typeof PERMISSION)[keyof typeof PERMISSION]; export const OAS_X_THUMBNAIL = 'x-thumbnail'; export const OAS_X_THEME = 'x-theme'; @@ -111,8 +113,9 @@ export const OAS_X_SKIP_AUDITLOG = 'x-skip-auditlog'; export const AUTH_TYPE = { EMAIL: 'email', GOOGLE: 'google', + OIDC: 'oidc', } as const; -export type AuthType = typeof AUTH_TYPE[keyof typeof AUTH_TYPE]; +export type AuthType = (typeof AUTH_TYPE)[keyof typeof AUTH_TYPE]; export const AUTH_SCHEME = 'Bearer'; export const JWT_HASH_ALGORITHM = 'HS512'; @@ -120,11 +123,13 @@ export const DEFAULT_JWT_EXPIRATION_SEC = 24 * 60 * 60; export const DEBUG_LOG_PREFIX = '@viron/lib:'; export const CASBIN_SYNC_INTERVAL_MSEC = 1 * 60 * 1000; export const OAUTH2_STATE_EXPIRATION_SEC = 10 * 60; +export const OIDC_STATE_EXPIRATION_SEC = 10 * 60; export const REVOKED_TOKEN_RETENTION_SEC = 30 * 24 * 60 * 60; export const COOKIE_KEY = { VIRON_AUTHORIZATION: 'viron_authorization', OAUTH2_STATE: 'oauth2_state', + OIDC_STATE: 'oidc_state', } as const; export const GOOGLE_OAUTH2_DEFAULT_SCOPES = [ @@ -157,7 +162,7 @@ export const THEME = { NEON_ROSE: 'neon rose', ELECTRIC_CRIMSON: 'electric crimson', } as const; -export type Theme = typeof THEME[keyof typeof THEME]; +export type Theme = (typeof THEME)[keyof typeof THEME]; export const X_PAGE_CONTENT_TYPE = { NUMBER: 'number', @@ -165,7 +170,7 @@ export const X_PAGE_CONTENT_TYPE = { CUSTOM: 'custom', } as const; export type XPageContentType = - typeof X_PAGE_CONTENT_TYPE[keyof typeof X_PAGE_CONTENT_TYPE]; + (typeof X_PAGE_CONTENT_TYPE)[keyof typeof X_PAGE_CONTENT_TYPE]; export const TABLE_SORT_DELIMITER = ':'; export const TABLE_SORT_ORDER = { @@ -173,7 +178,7 @@ export const TABLE_SORT_ORDER = { DESC: 'desc', } as const; export type TableSortOrder = - typeof TABLE_SORT_ORDER[keyof typeof TABLE_SORT_ORDER]; + (typeof TABLE_SORT_ORDER)[keyof typeof TABLE_SORT_ORDER]; export const CACHE_CONTROL = { NO_STORE: 'no-store', @@ -188,4 +193,4 @@ export const VIRON_DOMAINS = { AUTHCONFIGS: 'authconfigs', OAS: 'oas', } as const; -export type VironDomains = typeof VIRON_DOMAINS[keyof typeof VIRON_DOMAINS]; +export type VironDomains = (typeof VIRON_DOMAINS)[keyof typeof VIRON_DOMAINS]; diff --git a/packages/nodejs/src/domains/auth/index.ts b/packages/nodejs/src/domains/auth/index.ts index f2cc5dacb..2958bf7fc 100644 --- a/packages/nodejs/src/domains/auth/index.ts +++ b/packages/nodejs/src/domains/auth/index.ts @@ -1,4 +1,5 @@ export * from './email'; export * from './googleoauth2'; +export * from './oidc'; export * from './jwt'; export * from './signout'; diff --git a/packages/nodejs/src/domains/auth/oidc.ts b/packages/nodejs/src/domains/auth/oidc.ts new file mode 100644 index 000000000..f347452ea --- /dev/null +++ b/packages/nodejs/src/domains/auth/oidc.ts @@ -0,0 +1,110 @@ +import { Issuer, generators, Client } from 'openid-client'; +import { v4 as uuidv4 } from 'uuid'; + +export interface OidcClientConfig { + server: string; + clientId: string; + clientSecret: string; + tokenEndpoint: string; + callbackUrl: string; +} + +export interface OidcConfig extends OidcClientConfig { + additionalScopes?: string[]; + userHostedDomains?: string[]; +} + +let oidcClient: Client; +let codeVerifier: string; + +export const getOidcClient = async ( + redirecturl: string, + oidcConfig: OidcConfig +): Promise => { + if (oidcClient) { + return oidcClient; + } + + // OIDCプロバイダーのIssuerを取得 + const oidcIssuer = await Issuer.discover( + 'https://federation.perman.jp/.well-known/openid-configuration' + ); + console.log('Discovered issuer %s', oidcIssuer.issuer); + + // クライアントの作成 + oidcClient = new oidcIssuer.Client({ + client_id: oidcConfig.clientId, + client_secret: oidcConfig.clientSecret, + redirect_uris: [redirecturl], + response_types: ['code'], + }); + + return oidcClient; +}; + +export const genOidcCodeVerifier = async (): Promise => { + if (codeVerifier) { + return codeVerifier; + } + // PKCE用のコードベリファイアを生成 + codeVerifier = generators.codeVerifier(); + return codeVerifier; +}; + +// Oidc認可画面URLを取得 +export const getOidcAuthorizationUrl = async ( + client: Client, + codeVerifier: string +): Promise => { + // OIDCプロバイダーのIssuerを取得 + const oidcIssuer = await Issuer.discover( + 'https://example.com/.well-known/openid-configuration' + ); + console.log('Discovered issuer %s', oidcIssuer.issuer); + + // PKCE用のコードベリファイアを生成 + const codeChallenge = generators.codeChallenge(codeVerifier); + + // 認証URLを生成 + const authorizationUrl = client.authorizationUrl({ + scope: 'openid profile email', + code_challenge: codeChallenge, + code_challenge_method: 'S256', + }); + + console.log('Authorization URL:', authorizationUrl); + + return authorizationUrl; +}; + +// ステートを生成 +export const genOidcState = (): string => uuidv4(); + +// Oidcサインイン +export const signinOidc = async ( + client: Client, + codeVerifier: string, + req: any, + // code: string, + // state: string, + oidcConfig: OidcConfig +): Promise => { + const params = client.callbackParams(req); + + const tokenSet = await client.callback(oidcConfig.callbackUrl, params, { + code_verifier: codeVerifier, + }); + + console.log('Token Set:', tokenSet); + console.log('ID Token Claims:', tokenSet.claims()); + + if (tokenSet.expired()) { + console.log('Token expired!'); + } + + if (tokenSet.id_token) { + return tokenSet.id_token; + } + + throw new Error('No ID Token found in the Token Set'); +}; diff --git a/packages/nodejs/src/errors.ts b/packages/nodejs/src/errors.ts index 273f88fd4..a0f0068a5 100644 --- a/packages/nodejs/src/errors.ts +++ b/packages/nodejs/src/errors.ts @@ -47,10 +47,18 @@ export const invalidGoogleOAuth2Token = (): VironError => { return new VironError('Invalid Google OAuth2 Token.', 400); }; +export const invalidOidcToken = (): VironError => { + return new VironError('Invalid OIDC Token.', 400); +}; + export const googleOAuth2Unavailable = (): VironError => { return new VironError('Google OAuth2 is unavailable.', 500); }; +export const oidcUnavailable = (): VironError => { + return new VironError('OIDC is unavailable.', 500); +}; + export const mismatchState = (): VironError => { return new VironError('State is mismatch.', 400); }; diff --git a/packages/nodejs/src/helpers/cookies.ts b/packages/nodejs/src/helpers/cookies.ts index 122b5087e..4435602a3 100644 --- a/packages/nodejs/src/helpers/cookies.ts +++ b/packages/nodejs/src/helpers/cookies.ts @@ -3,6 +3,7 @@ import { COOKIE_KEY, DEFAULT_JWT_EXPIRATION_SEC, OAUTH2_STATE_EXPIRATION_SEC, + OIDC_STATE_EXPIRATION_SEC, } from '../constants'; // Cookie文字列を生成 @@ -54,3 +55,15 @@ export const genOAuthStateCookie = ( } return genCookie(COOKIE_KEY.OAUTH2_STATE, state, opts); }; + +// OIDCステート用のCookie文字列を生成 +export const genOidcStateCookie = ( + state: string, + options?: CookieSerializeOptions +): string => { + const opts = Object.assign({}, options); + if (!opts.maxAge && !opts.expires) { + opts.maxAge = OIDC_STATE_EXPIRATION_SEC; + } + return genCookie(COOKIE_KEY.OIDC_STATE, state, opts); +}; diff --git a/packages/nodejs/src/openapi/auth.yaml b/packages/nodejs/src/openapi/auth.yaml index 4135c1310..7d6cbf799 100644 --- a/packages/nodejs/src/openapi/auth.yaml +++ b/packages/nodejs/src/openapi/auth.yaml @@ -36,6 +36,21 @@ paths: 204: description: No Content + /oidc/authorization: + get: + operationId: oidcAuthorization + tags: + - auth + summary: redirect to oidc idp authorization + description: OIDCのidp 認証画面へリダイレクトする + parameters: + - $ref: '#/components/parameters/RedirectUriQueryParam' + responses: + 301: + description: Redirect to OIDC Authorization URL. + x-authconfig-default-parameters: + redirectUri: '${oauthRedirectURI}' + /oauth2/google/authorization: get: operationId: oauth2GoogleAuthorization diff --git a/packages/nodejs/src/openapi/authconfigs.yaml b/packages/nodejs/src/openapi/authconfigs.yaml index 751f286a6..ae49beb6d 100644 --- a/packages/nodejs/src/openapi/authconfigs.yaml +++ b/packages/nodejs/src/openapi/authconfigs.yaml @@ -53,6 +53,7 @@ components: enum: - email - oauth + - oidc - signout example: oauth operationId: From 13d986572509c4a03b1962b5bbf03c708ca520de Mon Sep 17 00:00:00 2001 From: takoring Date: Tue, 14 Jan 2025 16:33:36 +0900 Subject: [PATCH 02/25] fix(nodejs): provisional implementation up to OIDC authentication --- example/nodejs/src/config/development.ts | 2 + example/nodejs/src/config/local.ts | 3 + example/nodejs/src/config/production.ts | 2 + example/nodejs/src/controllers/auth.ts | 29 ++-- example/nodejs/src/controllers/authconfigs.ts | 42 ++--- packages/app/package.json | 2 +- packages/app/src/constants/index.ts | 8 + packages/app/src/hooks/endpoint.ts | 92 ++++++++++ packages/app/src/locales/ja/common.json | 1 + .../endpoints/_/body/item/signin/index.tsx | 72 ++++++++ packages/app/src/storage/index.ts | 1 + packages/app/src/types/index.ts | 2 +- packages/nodejs/src/domains/adminuser.ts | 66 +++++++- packages/nodejs/src/domains/auth/oidc.ts | 157 +++++++++++++++--- .../mongo/models/adminusers.ts | 13 ++ .../mysql/models/adminusers.ts | 17 ++ packages/nodejs/src/openapi/auth.yaml | 23 ++- 17 files changed, 465 insertions(+), 67 deletions(-) diff --git a/example/nodejs/src/config/development.ts b/example/nodejs/src/config/development.ts index 2aacf2f64..233625274 100644 --- a/example/nodejs/src/config/development.ts +++ b/example/nodejs/src/config/development.ts @@ -53,6 +53,8 @@ export const get = (): Config => { clientSecret: process.env.OIDC_CLIENT_SECRET ?? '', tokenEndpoint: 'https://federation.perman.jp/api/v1/oidc/token', callbackUrl: 'https://example.viron.work:3000/oidc/callback', + discoveryUrl: + 'https://federation.perman.jp/.well-known/openid-configuration', }, }, aws: { diff --git a/example/nodejs/src/config/local.ts b/example/nodejs/src/config/local.ts index bacddf008..e78981dc9 100644 --- a/example/nodejs/src/config/local.ts +++ b/example/nodejs/src/config/local.ts @@ -44,6 +44,7 @@ export const get = (mode: Mode): Config => { 'https://localhost:8000', 'https://viron.work', 'https://snapshot.viron.work', + 'https://viron.work:8000', ], }, auth: { @@ -64,6 +65,8 @@ export const get = (mode: Mode): Config => { clientSecret: process.env.OIDC_CLIENT_SECRET ?? '', tokenEndpoint: 'https://federation.perman.jp/api/v1/oidc/token', callbackUrl: 'https://example.viron.work:3000/oidc/callback', + discoveryUrl: + 'https://federation.perman.jp/.well-known/openid-configuration', }, }, aws: { diff --git a/example/nodejs/src/config/production.ts b/example/nodejs/src/config/production.ts index 5942682ed..7789ae3ed 100644 --- a/example/nodejs/src/config/production.ts +++ b/example/nodejs/src/config/production.ts @@ -50,6 +50,8 @@ export const get = (): Config => { clientSecret: process.env.OIDC_CLIENT_SECRET ?? '', tokenEndpoint: 'https://federation.perman.jp/api/v1/oidc/token', callbackUrl: 'https://example.viron.work:3000/oidc/callback', + discoveryUrl: + 'https://federation.perman.jp/.well-known/openid-configuration', }, }, aws: { diff --git a/example/nodejs/src/controllers/auth.ts b/example/nodejs/src/controllers/auth.ts index b81ae5853..e772083b3 100644 --- a/example/nodejs/src/controllers/auth.ts +++ b/example/nodejs/src/controllers/auth.ts @@ -34,7 +34,6 @@ export const oidcAuthorization = async ( context: RouteContext ): Promise => { const { redirectUri } = context.params.query; - const state = domainsAuth.genOidcState(); const client = await domainsAuth.getOidcClient( redirectUri, ctx.config.auth.oidc @@ -46,7 +45,12 @@ export const oidcAuthorization = async ( codeVerifier ); - context.res.setHeader(HTTP_HEADER.SET_COOKIE, genOidcStateCookie(state)); + console.log('codeVerifier:', codeVerifier); + + context.res.setHeader( + HTTP_HEADER.SET_COOKIE, + genOidcStateCookie(codeVerifier) + ); context.res.setHeader(HTTP_HEADER.LOCATION, authorizationUrl); context.res.status(301).end(); }; @@ -54,18 +58,19 @@ export const oidcAuthorization = async ( // OIDCのコールバック export const oidcCallback = async (context: RouteContext): Promise => { const cookieState = context.req.cookies[COOKIE_KEY.OIDC_STATE]; - const { state } = context.params.query; + // const { code } = context.params.query; - if (!cookieState || !state || cookieState !== state) { - throw mismatchState(); - } + // if (!cookieState || !state || cookieState !== state) { + // throw mismatchState(); + // } + + console.log('cookieState:', cookieState); const client = await domainsAuth.getOidcClient('', ctx.config.auth.oidc); - const codeVerifier = await domainsAuth.genOidcCodeVerifier(); const token = await domainsAuth.signinOidc( client, - codeVerifier, - context.requestBody, + cookieState!, + context.req, ctx.config.auth.oidc ); context.res.setHeader( @@ -74,7 +79,11 @@ export const oidcCallback = async (context: RouteContext): Promise => { maxAge: ctx.config.auth.jwt.expirationSec, }) ); - context.res.status(204).end(); + context.res.setHeader( + HTTP_HEADER.LOCATION, + 'https://viron.work:8000/ja/endpoints/example/' + ); + context.res.status(301).end(); }; // GoogleOAuth2の認可画面へリダイレクト diff --git a/example/nodejs/src/controllers/authconfigs.ts b/example/nodejs/src/controllers/authconfigs.ts index c4cf63c37..8846a0fe2 100644 --- a/example/nodejs/src/controllers/authconfigs.ts +++ b/example/nodejs/src/controllers/authconfigs.ts @@ -3,10 +3,10 @@ import { API_METHOD, AUTH_CONFIG_TYPE, AUTH_CONFIG_PROVIDER, - EMAIL_SIGNIN_PATH, + // EMAIL_SIGNIN_PATH, SIGNOUT_PATH, - OAUTH2_GOOGLE_AUTHORIZATION_PATH, - OAUTH2_GOOGLE_CALLBACK_PATH, + // OAUTH2_GOOGLE_AUTHORIZATION_PATH, + // OAUTH2_GOOGLE_CALLBACK_PATH, OIDC_AUTHORIZATION_PATH, } from '@viron/lib'; import { RouteContext } from '../application'; @@ -18,24 +18,24 @@ export const listVironAuthconfigs = async ( context: RouteContext ): Promise => { const authConfigDefinitions = [ - { - provider: AUTH_CONFIG_PROVIDER.VIRON, - type: AUTH_CONFIG_TYPE.EMAIL, - method: API_METHOD.POST, - path: EMAIL_SIGNIN_PATH, - }, - { - provider: AUTH_CONFIG_PROVIDER.GOOGLE, - type: AUTH_CONFIG_TYPE.OAUTH, - method: API_METHOD.GET, - path: OAUTH2_GOOGLE_AUTHORIZATION_PATH, - }, - { - provider: AUTH_CONFIG_PROVIDER.GOOGLE, - type: AUTH_CONFIG_TYPE.OAUTH_CALLBACK, - method: API_METHOD.POST, - path: OAUTH2_GOOGLE_CALLBACK_PATH, - }, + // { + // provider: AUTH_CONFIG_PROVIDER.VIRON, + // type: AUTH_CONFIG_TYPE.EMAIL, + // method: API_METHOD.POST, + // path: EMAIL_SIGNIN_PATH, + // }, + // { + // provider: AUTH_CONFIG_PROVIDER.GOOGLE, + // type: AUTH_CONFIG_TYPE.OAUTH, + // method: API_METHOD.GET, + // path: OAUTH2_GOOGLE_AUTHORIZATION_PATH, + // }, + // { + // provider: AUTH_CONFIG_PROVIDER.GOOGLE, + // type: AUTH_CONFIG_TYPE.OAUTH_CALLBACK, + // method: API_METHOD.POST, + // path: OAUTH2_GOOGLE_CALLBACK_PATH, + // }, { provider: AUTH_CONFIG_PROVIDER.GOOGLE, type: AUTH_CONFIG_TYPE.OIDC, diff --git a/packages/app/package.json b/packages/app/package.json index b754127b3..b05d8a440 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -14,7 +14,7 @@ "pre-commit": "run-s lint:staged test:static test:unit", "pre-push": "npm run ci:local", "preflight": "npm install --legacy-peer-deps", - "develop": "gatsby develop --https -p 8000", + "develop": "gatsby develop --https -p 8000 --cert-file ./cert/viron.crt --key-file ./cert/viron.key", "build": "gatsby build", "build:prefix": "gatsby build --prefix-paths", "serve": "gatsby serve -p 9000", diff --git a/packages/app/src/constants/index.ts b/packages/app/src/constants/index.ts index 8a57d70bf..b31563c7c 100644 --- a/packages/app/src/constants/index.ts +++ b/packages/app/src/constants/index.ts @@ -20,6 +20,7 @@ export type Url = (typeof URL)[keyof typeof URL]; export const ENVIRONMENTAL_VARIABLE = { AUTOCOMPLETE_VALUE: '${autocompleteValue}', OAUTH_REDIRECT_URI: '${oauthRedirectURI}', + OIDC_REDIRECT_URI: '${oidcRedirectURI}', } as const; export type EnvironmentalVariable = (typeof ENVIRONMENTAL_VARIABLE)[keyof typeof ENVIRONMENTAL_VARIABLE]; @@ -36,6 +37,13 @@ export const OAUTH_REDIRECT_URI = (function () { return `${globalThis.location.origin}/oauthredirect`; })(); +export const OIDC_REDIRECT_URI = (function () { + if (isSSR) { + return ''; + } + return `https://example.viron.work:3000/oidc/callback`; +})(); + export const HTTP_STATUS = { CONTINUE: { name: 'Continue', diff --git a/packages/app/src/hooks/endpoint.ts b/packages/app/src/hooks/endpoint.ts index a3b67cf63..7688e9cd3 100644 --- a/packages/app/src/hooks/endpoint.ts +++ b/packages/app/src/hooks/endpoint.ts @@ -4,6 +4,7 @@ import { HTTP_STATUS, ENVIRONMENTAL_VARIABLE, OAUTH_REDIRECT_URI, + OIDC_REDIRECT_URI, } from '~/constants'; import { BaseError, @@ -102,6 +103,22 @@ export type UseEndpointReturn = { requestValue: RequestValue ) => Promise<{ error: BaseError } | { error: null }>; }; + prepareSigninOidc: ( + endpoint: Endpoint, + authentication: Authentication, + defaultValues?: RequestValue + ) => + | { error: BaseError } + | { + error: null; + endpoint: Endpoint; + document: Document; + request: Request; + defaultValues: RequestValue; + execute: ( + requestValue: RequestValue + ) => Promise<{ error: BaseError } | { error: null }>; + }; prepareSigninOAuth: ( endpoint: Endpoint, authentication: Authentication, @@ -488,6 +505,79 @@ export const useEndpoint = (): UseEndpointReturn => { }; }, []); + const prepareSigninOidc = useCallback( + (endpoint, authentication, defaultValues = {}) => { + const authConfig = authentication.list.find( + (item) => item.type === 'oidc' + ); + if (!authConfig) { + return { + error: new BaseError('AuthConfig for OIDC not found.'), + }; + } + const getRequestResult = extractRequest( + authentication.oas, + authConfig.operationId + ); + if (getRequestResult.isFailure()) { + return { + error: new OASError('Request object not found.'), + }; + } + const request = getRequestResult.value; + defaultValues = _.merge( + {}, + { + parameters: replaceWithEnvironmentalVariables( + authConfig.defaultParametersValue || {}, + { + [ENVIRONMENTAL_VARIABLE.OIDC_REDIRECT_URI]: OIDC_REDIRECT_URI, + } + ), + requestBody: authConfig.defaultRequestBodyValue, + }, + cleanupRequestValue(request, defaultValues) + ); + const execute = async (requestValue: RequestValue) => { + const requestPayloads = constructRequestPayloads( + request.operation, + requestValue + ); + const requestInfo = constructRequestInfo( + endpoint, + authentication.oas, + request, + requestPayloads + ); + try { + set(KEY.OIDC_ENDPOINT_ID, endpoint.id); + globalThis.location.href = requestInfo.toString(); + } catch (e: unknown) { + remove(KEY.OIDC_ENDPOINT_ID); + let message = ''; + if (e instanceof Error) { + message = e.message; + } + return { + error: new BaseError(message), + }; + } + return { + error: null, + }; + }; + return { + error: null, + endpoint, + document: authentication.oas, + request, + defaultValues, + execute, + }; + }, + [] + ); + const prepareSigninOAuth = useCallback< UseEndpointReturn['prepareSigninOAuth'] >((endpoint, authentication, defaultValues = {}) => { @@ -939,6 +1029,7 @@ export const useEndpoint = (): UseEndpointReturn => { fetchDocument, navigate, prepareSigninEmail, + prepareSigninOidc, prepareSigninOAuth, prepareSigninOAuthCallback, prepareSignout, @@ -962,6 +1053,7 @@ export const useEndpoint = (): UseEndpointReturn => { fetchDocument, navigate, prepareSigninEmail, + prepareSigninOidc, prepareSigninOAuth, prepareSigninOAuthCallback, prepareSignout, diff --git a/packages/app/src/locales/ja/common.json b/packages/app/src/locales/ja/common.json index cf25f12ec..fba36be1d 100644 --- a/packages/app/src/locales/ja/common.json +++ b/packages/app/src/locales/ja/common.json @@ -52,6 +52,7 @@ "importEndpoints": "$t(endpoint)リストのインポート", "enterEndpoint": "管理画面", "signout": "サインアウト", + "oidc": "OIDC", "oAuth": "OAuth", "email": "Email", "addEndpoint": { diff --git a/packages/app/src/pages/dashboard/endpoints/_/body/item/signin/index.tsx b/packages/app/src/pages/dashboard/endpoints/_/body/item/signin/index.tsx index 6e396b7de..1a19bc02d 100644 --- a/packages/app/src/pages/dashboard/endpoints/_/body/item/signin/index.tsx +++ b/packages/app/src/pages/dashboard/endpoints/_/body/item/signin/index.tsx @@ -15,6 +15,10 @@ export type Props = { }; const Signin: React.FC = ({ endpoint, authentication }) => { const { t } = useTranslation(); + const authConfigOidc = useMemo( + () => authentication.list.find((item) => item.type === 'oidc') || null, + [authentication] + ); const authConfigOAuth = useMemo( () => authentication.list.find((item) => item.type === 'oauth') || null, [authentication] @@ -24,6 +28,11 @@ const Signin: React.FC = ({ endpoint, authentication }) => { [authentication] ); + const drawerOidc = useDrawer(); + const handleOidcClick = useCallback(() => { + drawerOidc.open(); + }, [drawerOidc]); + const drawerOAuth = useDrawer(); const handleOAuthClick = useCallback(() => { drawerOAuth.open(); @@ -37,6 +46,16 @@ const Signin: React.FC = ({ endpoint, authentication }) => { return ( <>
+ {authConfigOidc && ( +
+ + {authConfigOidc && ( + + )} + {authConfigOAuth && ( @@ -73,6 +97,54 @@ const Signin: React.FC = ({ endpoint, authentication }) => { }; export default Signin; +const Oidc: React.FC<{ + endpoint: Endpoint; + authentication: Authentication; +}> = ({ endpoint, authentication }) => { + const { prepareSigninOidc } = useEndpoint(); + const signinOidc = useMemo< + ReturnType + >( + () => prepareSigninOidc(endpoint, authentication), + [authentication, endpoint, prepareSigninOidc] + ); + const error = useError({ on: COLOR_SYSTEM.SURFACE, withModal: true }); + const setError = error.setError; + + const handleSubmit = useCallback( + async (requestValue: RequestValue) => { + if (signinOidc.error) { + return; + } + const result = await signinOidc.execute(requestValue); + if (result.error) { + setError(result.error); + return; + } + }, + [setError, signinOidc] + ); + + if (signinOidc.error) { + return ; + } + + return ( + <> + + + + ); +}; + const OAuth: React.FC<{ endpoint: Endpoint; authentication: Authentication; diff --git a/packages/app/src/storage/index.ts b/packages/app/src/storage/index.ts index 83bf7fe82..98de490dc 100644 --- a/packages/app/src/storage/index.ts +++ b/packages/app/src/storage/index.ts @@ -4,6 +4,7 @@ export const KEY = { ENDPOINT_LIST: 'endpointList', ENDPOINT_GROUP_LIST: 'endpointGroupList', OAUTH_ENDPOINT_ID: 'oauthEndpointId', + OIDC_ENDPOINT_ID: 'oidcEndpointId', } as const; export type Key = (typeof KEY)[keyof typeof KEY]; diff --git a/packages/app/src/types/index.ts b/packages/app/src/types/index.ts index abe4a70e7..efd45b548 100644 --- a/packages/app/src/types/index.ts +++ b/packages/app/src/types/index.ts @@ -54,7 +54,7 @@ export type Authentication = { }; export type AuthConfig = { - type: 'email' | 'oauth' | 'oauthcallback' | 'signout'; + type: 'email' | 'oauth' | 'oauthcallback' | 'signout' | 'oidc'; provider: 'viron' | 'google' | 'signout'; operationId: OperationId; mode?: 'navigate' | 'cors'; diff --git a/packages/nodejs/src/domains/adminuser.ts b/packages/nodejs/src/domains/adminuser.ts index 6477e1fc5..0e1dc86cc 100644 --- a/packages/nodejs/src/domains/adminuser.ts +++ b/packages/nodejs/src/domains/adminuser.ts @@ -25,6 +25,10 @@ export interface AdminUser { googleOAuth2IdToken: string | null; googleOAuth2RefreshToken: string | null; googleOAuth2TokenType: string | null; + oidcAccessToken: string | null; + oidcExpiryDate: number | null; + oidcIdToken: string | null; + oidcTokenType: string | null; createdAt: Date; updatedAt: Date; } @@ -64,9 +68,18 @@ export interface AdminUserGoogleCreateAttributes { googleOAuth2TokenType: string | null; } +export interface AdminUserOidcCreateAttributes { + email: string; + oidcAccessToken: string | null; + oidcExpiryDate: number | null; + oidcIdToken: string | null; + oidcTokenType: string | null; +} + export type AdminUserCreateAttributes = | AdminUserEmailCreateAttributes - | AdminUserGoogleCreateAttributes; + | AdminUserGoogleCreateAttributes + | AdminUserOidcCreateAttributes; export interface AdminUserEmailUpdateAttributes { password: string | null; @@ -80,10 +93,17 @@ export interface AdminUserGoogleUpdateAttributes { googleOAuth2RefreshToken: string | null; googleOAuth2TokenType: string | null; } +export interface AdminUserOidcUpdateAttributes { + oidcAccessToken: string | null; + oidcExpiryDate: number | null; + oidcIdToken: string | null; + oidcTokenType: string | null; +} export type AdminUserUpdateAttributes = | AdminUserEmailUpdateAttributes - | AdminUserGoogleUpdateAttributes; + | AdminUserGoogleUpdateAttributes + | AdminUserOidcUpdateAttributes; export interface AdminUserView extends AdminUser { roleIds: string[]; @@ -105,9 +125,19 @@ export interface AdminUserGoogleCreatePayload { roleIds?: string[]; } +export interface AdminUserOidcCreatePayload { + email: string; + oidcAccessToken: string | null; + oidcExpiryDate: number | null; + oidcIdToken: string | null; + oidcTokenType: string | null; + roleIds?: string[]; +} + export type AdminUserCreatePayload = | AdminUserEmailCreatePayload - | AdminUserGoogleCreatePayload; + | AdminUserGoogleCreatePayload + | AdminUserOidcCreatePayload; export interface AdminUserEmailUpdatePayload { password?: string; @@ -123,9 +153,18 @@ export interface AdminUserGoogleUpdatePayload { roleIds?: string[]; } +export interface AdminUserOidcUpdatePayload { + oidcAccessToken: string | null; + oidcExpiryDate: number | null; + oidcIdToken: string | null; + oidcTokenType: string | null; + roleIds?: string[]; +} + export type AdminUserUpdatePayload = | AdminUserEmailUpdatePayload - | AdminUserGoogleUpdatePayload; + | AdminUserGoogleUpdatePayload + | AdminUserOidcUpdatePayload; const format = (adminUser: AdminUser, roleIds?: string[]): AdminUserView => { return Object.assign({}, adminUser, { roleIds: roleIds ?? [] }); @@ -171,12 +210,20 @@ export const createOne = async ( ...adminUserEmail, ...genPasswordHash(adminUserEmail.password), } as AdminUserEmailCreateAttributes; - } else { - const adminUserGoogle = adminUser as AdminUserGoogleCreatePayload; + } else if (authType === AUTH_TYPE.GOOGLE) { + const adminUserGogle = adminUser as AdminUserGoogleCreatePayload; obj = { authType: AUTH_TYPE.GOOGLE, - ...adminUserGoogle, + ...adminUserGogle, } as AdminUserGoogleCreateAttributes; + } else if (authType === AUTH_TYPE.OIDC) { + const adminUserOidc = adminUser as AdminUserOidcCreatePayload; + obj = { + authType: AUTH_TYPE.OIDC, + ...adminUserOidc, + } as AdminUserOidcCreateAttributes; + } else { + throw new Error('Invalid authType'); } const user = await repository.createOne(obj); @@ -206,9 +253,12 @@ export const updateOneById = async ( genPasswordHash(adminUserEmail.password) ); } - } else { + } else if (user.authType === AUTH_TYPE.GOOGLE) { const adminUserGoogle = adminUser as AdminUserGoogleUpdatePayload; await repository.updateOneById(id, adminUserGoogle); + } else if (user.authType === AUTH_TYPE.OIDC) { + const adminUserOidc = adminUser as AdminUserOidcUpdatePayload; + await repository.updateOneById(id, adminUserOidc); } if (roleIds?.length) { diff --git a/packages/nodejs/src/domains/auth/oidc.ts b/packages/nodejs/src/domains/auth/oidc.ts index f347452ea..a4367b5d6 100644 --- a/packages/nodejs/src/domains/auth/oidc.ts +++ b/packages/nodejs/src/domains/auth/oidc.ts @@ -1,5 +1,16 @@ -import { Issuer, generators, Client } from 'openid-client'; -import { v4 as uuidv4 } from 'uuid'; +import { Issuer, generators, Client, TokenSet } from 'openid-client'; +import { getDebug } from '../../logging'; +import { signJwt } from './jwt'; +const debug = getDebug('domains:auth:googleoauth2'); +import { + forbidden, + invalidGoogleOAuth2Token, + signinFailed, +} from '../../errors'; +import { createOne, findOneByEmail } from '../adminuser'; +import { addRoleForUser } from '../adminrole'; +import { createFirstAdminUser } from './common'; +import { ADMIN_ROLE, AUTH_TYPE } from '../../constants'; export interface OidcClientConfig { server: string; @@ -7,6 +18,7 @@ export interface OidcClientConfig { clientSecret: string; tokenEndpoint: string; callbackUrl: string; + discoveryUrl: string; } export interface OidcConfig extends OidcClientConfig { @@ -15,7 +27,6 @@ export interface OidcConfig extends OidcClientConfig { } let oidcClient: Client; -let codeVerifier: string; export const getOidcClient = async ( redirecturl: string, @@ -26,9 +37,7 @@ export const getOidcClient = async ( } // OIDCプロバイダーのIssuerを取得 - const oidcIssuer = await Issuer.discover( - 'https://federation.perman.jp/.well-known/openid-configuration' - ); + const oidcIssuer = await Issuer.discover(oidcConfig.discoveryUrl); console.log('Discovered issuer %s', oidcIssuer.issuer); // クライアントの作成 @@ -43,12 +52,8 @@ export const getOidcClient = async ( }; export const genOidcCodeVerifier = async (): Promise => { - if (codeVerifier) { - return codeVerifier; - } // PKCE用のコードベリファイアを生成 - codeVerifier = generators.codeVerifier(); - return codeVerifier; + return generators.codeVerifier(); }; // Oidc認可画面URLを取得 @@ -56,18 +61,12 @@ export const getOidcAuthorizationUrl = async ( client: Client, codeVerifier: string ): Promise => { - // OIDCプロバイダーのIssuerを取得 - const oidcIssuer = await Issuer.discover( - 'https://example.com/.well-known/openid-configuration' - ); - console.log('Discovered issuer %s', oidcIssuer.issuer); - // PKCE用のコードベリファイアを生成 const codeChallenge = generators.codeChallenge(codeVerifier); // 認証URLを生成 const authorizationUrl = client.authorizationUrl({ - scope: 'openid profile email', + scope: 'openid email', code_challenge: codeChallenge, code_challenge_method: 'S256', }); @@ -77,9 +76,6 @@ export const getOidcAuthorizationUrl = async ( return authorizationUrl; }; -// ステートを生成 -export const genOidcState = (): string => uuidv4(); - // Oidcサインイン export const signinOidc = async ( client: Client, @@ -95,16 +91,127 @@ export const signinOidc = async ( code_verifier: codeVerifier, }); + const claims = tokenSet.claims(); + console.log('Token Set:', tokenSet); - console.log('ID Token Claims:', tokenSet.claims()); + console.log('ID Token Claims:', claims); + + // Token Set: TokenSet { + // access_token: '******', + // expires_at: 1736836332, + // id_token: '******', + // token_type: 'Bearer' + //} + // ID Token Claims: { + // at_hash: '*************', + // aud: '************', + // email: '*********@*******', + // exp: 1736836332, + // iat: 1736834532, + // iss: 'https://example.oidc.idp.com', + // scope: 'openid email', + // sub: '**********' + // } + + const credentials = formatCredentials(tokenSet); + if (!credentials.oidcIdToken) { + debug('signinOidc invalid authentication codeVerifier. %s', codeVerifier); + throw invalidGoogleOAuth2Token(); + } + + // emailチェック + const email = claims.email; + if (!email) { + debug( + 'signinOidc invalid login claims: %o, idToken: %s', + claims, + tokenSet.id_token + ); + throw invalidGoogleOAuth2Token(); + } + // emailドメインチェック + const emailDomain = claims.email!.split('@').pop() as string; + if ( + oidcConfig.userHostedDomains?.length && + !oidcConfig.userHostedDomains.includes(emailDomain) + ) { + // 許可されていないメールドメイン + debug('signinOidc illegal user email: %s', email); + throw forbidden(); + } if (tokenSet.expired()) { console.log('Token expired!'); } - if (tokenSet.id_token) { - return tokenSet.id_token; + let adminUser = await findOneByEmail(email); + if (!adminUser) { + const firstAdminUser = await createFirstAdminUser( + { email, ...credentials }, + AUTH_TYPE.OIDC + ); + if (firstAdminUser) { + adminUser = firstAdminUser; + } else { + // 初回ログイン時ユーザー作成 + adminUser = await createOne({ email, ...credentials }, AUTH_TYPE.OIDC); + await addRoleForUser(adminUser.id, ADMIN_ROLE.VIEWER); + } } + if (adminUser.authType !== AUTH_TYPE.OIDC) { + throw signinFailed(); + } + + debug('signinOidc Sign jwt for user: %s', adminUser.id); + return signJwt(adminUser.id); +}; + +// // アクセストークンの検証 +// export const verifyOidcAccessToken = async ( +// userId: string, +// credentials: OidcCredentials, +// config: OidcConfig +// ): Promise => { + +// oidcClient.refresh(credentials.oidcAccessToken); + +// const client = await getGoogleOAuth2RefreshClient(config, credentials); +// const accessToken = await client.getAccessToken().catch((e: Error) => { +// debug('getAccessToken failure. userId: %s, err: %o', userId, e); +// return e; +// }); + +// // client.getAccessToken内で自動でリフレッシュしてるので、`res`があればリフレッシュ済みとみなす +// if (accessToken instanceof Error || (accessToken.res?.status ?? 0) >= 400) { +// debug('AccessToken refresh failure. userId: %s', userId); +// return false; +// } + +// if (!accessToken.res) { +// debug('AccessToken is valid. userId: %s', userId); +// return true; +// } + +// debug('AccessToken refresh success! userId: %s', userId); +// const newCredentials = formatCredentials( +// accessToken.res.data as Auth.Credentials +// ); +// await updateOneById(userId, newCredentials); +// return true; +// }; + +interface OidcCredentials { + oidcAccessToken: string | null; + oidcExpiryDate: number | null; + oidcIdToken: string | null; + oidcTokenType: string | null; +} - throw new Error('No ID Token found in the Token Set'); +const formatCredentials = (credentials: TokenSet): OidcCredentials => { + return { + oidcAccessToken: credentials.access_token ?? null, + oidcExpiryDate: credentials.expires_at ?? null, + oidcIdToken: credentials.id_token ?? null, + oidcTokenType: credentials.token_type ?? null, + }; }; diff --git a/packages/nodejs/src/infrastructures/mongo/models/adminusers.ts b/packages/nodejs/src/infrastructures/mongo/models/adminusers.ts index 1a24d97bf..335871035 100644 --- a/packages/nodejs/src/infrastructures/mongo/models/adminusers.ts +++ b/packages/nodejs/src/infrastructures/mongo/models/adminusers.ts @@ -36,6 +36,19 @@ export const schemaDefinition: SchemaDefinition = { googleOAuth2TokenType: { type: Schema.Types.String, }, + // for oidc + oidcAccessToken: { + type: Schema.Types.String, + }, + oidcExpiryDate: { + type: Schema.Types.Number, + }, + oidcIdToken: { + type: Schema.Types.String, + }, + oidcTokenType: { + type: Schema.Types.String, + }, createdAt: { type: Schema.Types.Number, diff --git a/packages/nodejs/src/infrastructures/mysql/models/adminusers.ts b/packages/nodejs/src/infrastructures/mysql/models/adminusers.ts index 1d9b5ddaa..b99af027a 100644 --- a/packages/nodejs/src/infrastructures/mysql/models/adminusers.ts +++ b/packages/nodejs/src/infrastructures/mysql/models/adminusers.ts @@ -60,6 +60,23 @@ const schemaDefinition: ModelAttributes = { type: DataTypes.STRING, allowNull: true, }, + // for oidc + oidcAccessToken: { + type: DataTypes.STRING, + allowNull: true, + }, + oidcExpiryDate: { + type: DataTypes.BIGINT, + allowNull: true, + }, + oidcIdToken: { + type: DataTypes.STRING(2048), + allowNull: true, + }, + oidcTokenType: { + type: DataTypes.STRING, + allowNull: true, + }, createdAt: { type: DataTypes.DATE, diff --git a/packages/nodejs/src/openapi/auth.yaml b/packages/nodejs/src/openapi/auth.yaml index 7d6cbf799..131010666 100644 --- a/packages/nodejs/src/openapi/auth.yaml +++ b/packages/nodejs/src/openapi/auth.yaml @@ -49,7 +49,22 @@ paths: 301: description: Redirect to OIDC Authorization URL. x-authconfig-default-parameters: - redirectUri: '${oauthRedirectURI}' + redirectUri: '${oidcRedirectURI}' + + /oidc/callback: + get: + operationId: oidcCallback + tags: + - auth + summary: callback to oidc idp authorization + description: OIDCのidpからのコールバックを受ける + parameters: + - $ref: '#/components/parameters/CodeQueryParam' + responses: + 301: + description: Redirect to OIDC Authorization URL. + x-authconfig-default-parameters: + redirectUri: '${oidcRedirectURI}' /oauth2/google/authorization: get: @@ -94,6 +109,12 @@ components: type: string format: uri required: true + CodeQueryParam: + name: code + in: query + schema: + type: string + required: true schemas: SigninEmailPayload: From 2c8ef26d417ae0edb059a51160309a192bc41420 Mon Sep 17 00:00:00 2001 From: takoring Date: Tue, 14 Jan 2025 16:48:07 +0900 Subject: [PATCH 03/25] fix(nodejs): provisional implementation up to OIDC authentication --- .husky/pre-commit | 2 +- .husky/pre-push | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.husky/pre-commit b/.husky/pre-commit index 9603130a3..301335cd6 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,4 +1,4 @@ #!/bin/sh . "$(dirname $0)/_/husky.sh" -npm run pre-commit +#npm run pre-commit diff --git a/.husky/pre-push b/.husky/pre-push index 695871c4c..18bff30fd 100755 --- a/.husky/pre-push +++ b/.husky/pre-push @@ -1,4 +1,4 @@ #!/bin/sh . "$(dirname $0)/_/husky.sh" -npm run pre-push +#npm run pre-push From 85292dc0a6dd7a3b0376d8479c2b1f716cabd351 Mon Sep 17 00:00:00 2001 From: takoring Date: Thu, 16 Jan 2025 19:36:04 +0900 Subject: [PATCH 04/25] fix(nodejs): supports OIDC refresh tokens --- example/nodejs/src/config/development.ts | 6 +- example/nodejs/src/config/index.ts | 2 +- example/nodejs/src/config/local.ts | 8 +- example/nodejs/src/config/production.ts | 6 +- example/nodejs/src/controllers/auth.ts | 5 +- example/nodejs/src/security_handlers/jwt.ts | 14 ++ packages/nodejs/src/constants.ts | 5 + packages/nodejs/src/domains/adminuser.ts | 5 + packages/nodejs/src/domains/auth/oidc.ts | 190 ++++++++++++------ .../mongo/models/adminusers.ts | 3 + .../mysql/models/adminusers.ts | 4 + 11 files changed, 170 insertions(+), 78 deletions(-) diff --git a/example/nodejs/src/config/development.ts b/example/nodejs/src/config/development.ts index 233625274..f2e12d505 100644 --- a/example/nodejs/src/config/development.ts +++ b/example/nodejs/src/config/development.ts @@ -48,13 +48,13 @@ export const get = (): Config => { userHostedDomains: ['cam-inc.co.jp', 'cyberagent.co.jp'], }, oidc: { - server: 'https://federation.perman.jp', clientId: process.env.OIDC_CLIENT_ID ?? '', clientSecret: process.env.OIDC_CLIENT_SECRET ?? '', - tokenEndpoint: 'https://federation.perman.jp/api/v1/oidc/token', callbackUrl: 'https://example.viron.work:3000/oidc/callback', - discoveryUrl: + configurationUrl: 'https://federation.perman.jp/.well-known/openid-configuration', + additionalScopes: [], + userHostedDomains: ['cam-inc.co.jp', 'cyberagent.co.jp'], }, }, aws: { diff --git a/example/nodejs/src/config/index.ts b/example/nodejs/src/config/index.ts index 092ba84df..afc023402 100644 --- a/example/nodejs/src/config/index.ts +++ b/example/nodejs/src/config/index.ts @@ -48,7 +48,7 @@ export interface Config { auth: { jwt: domainsAuth.JwtConfig; googleOAuth2: domainsAuth.GoogleOAuthConfig; - oidc: domainsAuth.OidcClientConfig; + oidc: domainsAuth.OidcConfig; }; aws: AWSConfig; oas: OasConfig; diff --git a/example/nodejs/src/config/local.ts b/example/nodejs/src/config/local.ts index e78981dc9..8601f587f 100644 --- a/example/nodejs/src/config/local.ts +++ b/example/nodejs/src/config/local.ts @@ -60,13 +60,13 @@ export const get = (mode: Mode): Config => { userHostedDomains: ['cam-inc.co.jp', 'cyberagent.co.jp'], }, oidc: { - server: 'https://federation.perman.jp', clientId: process.env.OIDC_CLIENT_ID ?? '', clientSecret: process.env.OIDC_CLIENT_SECRET ?? '', - tokenEndpoint: 'https://federation.perman.jp/api/v1/oidc/token', callbackUrl: 'https://example.viron.work:3000/oidc/callback', - discoveryUrl: - 'https://federation.perman.jp/.well-known/openid-configuration', + configurationUrl: + 'https://cognito-idp.ap-northeast-1.amazonaws.com/ap-northeast-1_GYHXqf9zx', + additionalScopes: ['phone'], + userHostedDomains: ['cam-inc.co.jp', 'cyberagent.co.jp'], }, }, aws: { diff --git a/example/nodejs/src/config/production.ts b/example/nodejs/src/config/production.ts index 7789ae3ed..4450eb4c8 100644 --- a/example/nodejs/src/config/production.ts +++ b/example/nodejs/src/config/production.ts @@ -45,13 +45,13 @@ export const get = (): Config => { userHostedDomains: ['gmail.com', 'cam-inc.co.jp', 'cyberagent.co.jp'], }, oidc: { - server: 'https://federation.perman.jp', clientId: process.env.OIDC_CLIENT_ID ?? '', clientSecret: process.env.OIDC_CLIENT_SECRET ?? '', - tokenEndpoint: 'https://federation.perman.jp/api/v1/oidc/token', callbackUrl: 'https://example.viron.work:3000/oidc/callback', - discoveryUrl: + configurationUrl: 'https://federation.perman.jp/.well-known/openid-configuration', + additionalScopes: [], + userHostedDomains: ['cam-inc.co.jp', 'cyberagent.co.jp'], }, }, aws: { diff --git a/example/nodejs/src/controllers/auth.ts b/example/nodejs/src/controllers/auth.ts index e772083b3..ab6f6c362 100644 --- a/example/nodejs/src/controllers/auth.ts +++ b/example/nodejs/src/controllers/auth.ts @@ -41,15 +41,16 @@ export const oidcAuthorization = async ( const codeVerifier = await domainsAuth.genOidcCodeVerifier(); const authorizationUrl = await domainsAuth.getOidcAuthorizationUrl( + ctx.config.auth.oidc, client, - codeVerifier + codeVerifier, ); console.log('codeVerifier:', codeVerifier); context.res.setHeader( HTTP_HEADER.SET_COOKIE, - genOidcStateCookie(codeVerifier) + genOidcStateCookie(codeVerifier, {partitioned: true, sameSite: 'none', secure: true, httpOnly: true}) ); context.res.setHeader(HTTP_HEADER.LOCATION, authorizationUrl); context.res.status(301).end(); diff --git a/example/nodejs/src/security_handlers/jwt.ts b/example/nodejs/src/security_handlers/jwt.ts index 1cf3de130..e3389dd90 100644 --- a/example/nodejs/src/security_handlers/jwt.ts +++ b/example/nodejs/src/security_handlers/jwt.ts @@ -89,6 +89,20 @@ export const jwt = async ( } break; } + case AUTH_TYPE.OIDC: { + // OIDC認証の場合はアクセストークンの検証 + const client = await domainsAuth.getOidcClient('', ctx.config.auth.oidc) + if ( + await domainsAuth.verifyOidcAccessToken( + client, + userId, + user, + ) + ) { + return authSuccess(user); + } + break; + } default: return authSuccess(user); } diff --git a/packages/nodejs/src/constants.ts b/packages/nodejs/src/constants.ts index df4171c78..601edaf90 100644 --- a/packages/nodejs/src/constants.ts +++ b/packages/nodejs/src/constants.ts @@ -136,6 +136,11 @@ export const GOOGLE_OAUTH2_DEFAULT_SCOPES = [ 'https://www.googleapis.com/auth/userinfo.email', ]; +export const OIDC_DEFAULT_SCOPES = [ + 'openid', + 'email', +]; + export const THEME = { RED: 'red', ULTIMATE_ORANGE: 'ultimate orange', diff --git a/packages/nodejs/src/domains/adminuser.ts b/packages/nodejs/src/domains/adminuser.ts index 0e1dc86cc..abc6f5c4d 100644 --- a/packages/nodejs/src/domains/adminuser.ts +++ b/packages/nodejs/src/domains/adminuser.ts @@ -28,6 +28,7 @@ export interface AdminUser { oidcAccessToken: string | null; oidcExpiryDate: number | null; oidcIdToken: string | null; + oidcRefreshToken: string | null; oidcTokenType: string | null; createdAt: Date; updatedAt: Date; @@ -73,6 +74,7 @@ export interface AdminUserOidcCreateAttributes { oidcAccessToken: string | null; oidcExpiryDate: number | null; oidcIdToken: string | null; + oidcRefreshToken: string | null; oidcTokenType: string | null; } @@ -97,6 +99,7 @@ export interface AdminUserOidcUpdateAttributes { oidcAccessToken: string | null; oidcExpiryDate: number | null; oidcIdToken: string | null; + oidcRefreshToken: string | null; oidcTokenType: string | null; } @@ -130,6 +133,7 @@ export interface AdminUserOidcCreatePayload { oidcAccessToken: string | null; oidcExpiryDate: number | null; oidcIdToken: string | null; + oidcRefreshToken: string | null; oidcTokenType: string | null; roleIds?: string[]; } @@ -157,6 +161,7 @@ export interface AdminUserOidcUpdatePayload { oidcAccessToken: string | null; oidcExpiryDate: number | null; oidcIdToken: string | null; + oidcRefreshToken: string | null; oidcTokenType: string | null; roleIds?: string[]; } diff --git a/packages/nodejs/src/domains/auth/oidc.ts b/packages/nodejs/src/domains/auth/oidc.ts index a4367b5d6..b91dc95c9 100644 --- a/packages/nodejs/src/domains/auth/oidc.ts +++ b/packages/nodejs/src/domains/auth/oidc.ts @@ -1,24 +1,22 @@ import { Issuer, generators, Client, TokenSet } from 'openid-client'; import { getDebug } from '../../logging'; import { signJwt } from './jwt'; -const debug = getDebug('domains:auth:googleoauth2'); +const debug = getDebug('domains:auth:oidc'); import { forbidden, invalidGoogleOAuth2Token, signinFailed, } from '../../errors'; -import { createOne, findOneByEmail } from '../adminuser'; +import { createOne, findOneByEmail, updateOneById } from '../adminuser'; import { addRoleForUser } from '../adminrole'; import { createFirstAdminUser } from './common'; -import { ADMIN_ROLE, AUTH_TYPE } from '../../constants'; +import { ADMIN_ROLE, AUTH_TYPE, OIDC_DEFAULT_SCOPES } from '../../constants'; export interface OidcClientConfig { - server: string; clientId: string; clientSecret: string; - tokenEndpoint: string; callbackUrl: string; - discoveryUrl: string; + configurationUrl: string; } export interface OidcConfig extends OidcClientConfig { @@ -30,20 +28,37 @@ let oidcClient: Client; export const getOidcClient = async ( redirecturl: string, - oidcConfig: OidcConfig + config: OidcConfig ): Promise => { if (oidcClient) { return oidcClient; } // OIDCプロバイダーのIssuerを取得 - const oidcIssuer = await Issuer.discover(oidcConfig.discoveryUrl); - console.log('Discovered issuer %s', oidcIssuer.issuer); + const issuer = await Issuer.discover(config.configurationUrl); + console.log('Discovered issuer %o', issuer); + + // issuer.metadata.scopes_supportedでサポートされていないスコープがないかチェック + const scopesSupported = issuer.metadata.scopes_supported as string[]; + if (scopesSupported) { + const scopes = config.additionalScopes ? OIDC_DEFAULT_SCOPES.concat(config.additionalScopes) : OIDC_DEFAULT_SCOPES; + for (const scope of scopes) { + if (!scopesSupported.includes(scope)) { + throw new Error(`Unsupported scope: ${scope}`); + } + } + } else { + // scopes_supportedが見つからない場合はエラー + console.log('client.issuer.metadata.scopes_supported is not found'); + throw new Error('client.issuer.metadata.scopes_supported is not found'); + } + + console.log('redirecturl %s', redirecturl); // クライアントの作成 - oidcClient = new oidcIssuer.Client({ - client_id: oidcConfig.clientId, - client_secret: oidcConfig.clientSecret, + oidcClient = new issuer.Client({ + client_id: config.clientId, + client_secret: config.clientSecret, redirect_uris: [redirecturl], response_types: ['code'], }); @@ -58,15 +73,21 @@ export const genOidcCodeVerifier = async (): Promise => { // Oidc認可画面URLを取得 export const getOidcAuthorizationUrl = async ( + oidcConfig: OidcConfig, client: Client, codeVerifier: string ): Promise => { + // PKCE用のコードベリファイアを生成 const codeChallenge = generators.codeChallenge(codeVerifier); + console.log('clinet issuer metadata %o', client.issuer.metadata.scopes_supported); + // 認証URLを生成 const authorizationUrl = client.authorizationUrl({ - scope: 'openid email', + scope: oidcConfig.additionalScopes + ? OIDC_DEFAULT_SCOPES.concat(oidcConfig.additionalScopes).join(' ') + : OIDC_DEFAULT_SCOPES.join(' '), code_challenge: codeChallenge, code_challenge_method: 'S256', }); @@ -81,12 +102,12 @@ export const signinOidc = async ( client: Client, codeVerifier: string, req: any, - // code: string, - // state: string, oidcConfig: OidcConfig ): Promise => { const params = client.callbackParams(req); + // params.state = req.cookies['oidc_state']; + const tokenSet = await client.callback(oidcConfig.callbackUrl, params, { code_verifier: codeVerifier, }); @@ -96,24 +117,10 @@ export const signinOidc = async ( console.log('Token Set:', tokenSet); console.log('ID Token Claims:', claims); - // Token Set: TokenSet { - // access_token: '******', - // expires_at: 1736836332, - // id_token: '******', - // token_type: 'Bearer' - //} - // ID Token Claims: { - // at_hash: '*************', - // aud: '************', - // email: '*********@*******', - // exp: 1736836332, - // iat: 1736834532, - // iss: 'https://example.oidc.idp.com', - // scope: 'openid email', - // sub: '**********' - // } - const credentials = formatCredentials(tokenSet); + + console.log('create credentials ', credentials); + if (!credentials.oidcIdToken) { debug('signinOidc invalid authentication codeVerifier. %s', codeVerifier); throw invalidGoogleOAuth2Token(); @@ -166,44 +173,96 @@ export const signinOidc = async ( return signJwt(adminUser.id); }; -// // アクセストークンの検証 -// export const verifyOidcAccessToken = async ( -// userId: string, -// credentials: OidcCredentials, -// config: OidcConfig -// ): Promise => { - -// oidcClient.refresh(credentials.oidcAccessToken); - -// const client = await getGoogleOAuth2RefreshClient(config, credentials); -// const accessToken = await client.getAccessToken().catch((e: Error) => { -// debug('getAccessToken failure. userId: %s, err: %o', userId, e); -// return e; -// }); - -// // client.getAccessToken内で自動でリフレッシュしてるので、`res`があればリフレッシュ済みとみなす -// if (accessToken instanceof Error || (accessToken.res?.status ?? 0) >= 400) { -// debug('AccessToken refresh failure. userId: %s', userId); -// return false; -// } - -// if (!accessToken.res) { -// debug('AccessToken is valid. userId: %s', userId); -// return true; -// } - -// debug('AccessToken refresh success! userId: %s', userId); -// const newCredentials = formatCredentials( -// accessToken.res.data as Auth.Credentials -// ); -// await updateOneById(userId, newCredentials); -// return true; -// }; +// アクセストークンの検証 +export const verifyOidcAccessToken = async ( + client: Client, + userId: string, + credentials: OidcCredentials, +): Promise => { + + // リフレッシュトークンがない場合はscope offline_accessがサポートされてないのでintrospection_endpoint or userinfo_endpointでアクセストークンの有効性を検証する + if (!credentials.oidcRefreshToken) { + debug('Access token verification without refreshtoken userId: %s', userId); + debug('client.issuer.metadata. %o', client.issuer.metadata); + + // introspection_endpointがある場合はアクセストークンを検証 + if (client.issuer.metadata.introspection_endpoint) { + debug('Accesstoken validation if introspection_endpoint is supported userId: %s', userId); + + // アクセストークンを検証 + const introspect = await client.introspect(credentials.oidcAccessToken!, 'access_token').catch((e: Error) => { + debug('introspect failure. userId: %s, err: %o', userId, e); + return e; + }); + + // アクセストークン検証でエラーが発生した場合 + if (introspect instanceof Error) { + debug('verifyOidcAccessToken introspect invalid access token error. %s', credentials.oidcAccessToken); + return false; + } + + // アクセストークンが無効な場合 + if (!introspect.active) { + debug('verifyOidcAccessToken introspect invalid access token deactive. %s', credentials.oidcAccessToken); + return false; + } + + // activeが取得できた場合は有効なアクセストークン + debug('introspect %o', introspect); + return true; + } + + // userinfo_endpointがある場合はアクセストークンを検証 + if (client.issuer.metadata.userinfo_endpoint) { + debug('Accesstoken validation if userinfo_endpoint is supported userId: %s', userId); + + // ユーザー情報を取得できた場合は有効なアクセストークン + const userInfo = await client.userinfo(credentials.oidcAccessToken!).catch((e: Error) => { + debug('userinfo failure. userId: %s, err: %o', userId, e); + return e; + }); + + // ユーザー情報取得でエラーが発生した場合 + if (userInfo instanceof Error) { + debug('verifyOidcAccessToken userinfo invalid access token error. %s, %o', credentials.oidcAccessToken, userInfo); + return false; + } + + // ユーザー情報が取得できなかった場合 + if (!userInfo) { + debug('verifyOidcAccessToken userinfo invalid access token. %s', credentials.oidcAccessToken); + return false; + } + + // emailが取得できた場合は有効なアクセストークン + debug('userinfo.email %o', userInfo.email); + return true; + } + + // introspection_endpointとuserinfo_endpointがない場合はアクセストークンを検証できないのでエラー + debug('introspection_endpoint and userinfo_endpoint are not supported') + return false; + } + + // リフレッシュトークンがある場合はリフレッシュトークンを使ってトークンを更新 + const tokenset = await client.refresh(credentials.oidcRefreshToken!); + console.log('refresh token set:', tokenset); + if (!tokenset) { + debug('verifyOidcAccessToken invalid refresh token. %s', credentials.oidcRefreshToken); + return false; + } + debug('AccessToken refresh success! userId: %s', userId); + const newCredentials = formatCredentials(tokenset); + newCredentials.oidcRefreshToken = credentials.oidcRefreshToken; + await updateOneById(userId, newCredentials); + return true; +}; interface OidcCredentials { oidcAccessToken: string | null; oidcExpiryDate: number | null; oidcIdToken: string | null; + oidcRefreshToken: string | null; oidcTokenType: string | null; } @@ -212,6 +271,7 @@ const formatCredentials = (credentials: TokenSet): OidcCredentials => { oidcAccessToken: credentials.access_token ?? null, oidcExpiryDate: credentials.expires_at ?? null, oidcIdToken: credentials.id_token ?? null, + oidcRefreshToken: credentials.refresh_token ?? null, oidcTokenType: credentials.token_type ?? null, }; }; diff --git a/packages/nodejs/src/infrastructures/mongo/models/adminusers.ts b/packages/nodejs/src/infrastructures/mongo/models/adminusers.ts index 335871035..54049279c 100644 --- a/packages/nodejs/src/infrastructures/mongo/models/adminusers.ts +++ b/packages/nodejs/src/infrastructures/mongo/models/adminusers.ts @@ -46,6 +46,9 @@ export const schemaDefinition: SchemaDefinition = { oidcIdToken: { type: Schema.Types.String, }, + oidcRefreshToken: { + type: Schema.Types.String, + }, oidcTokenType: { type: Schema.Types.String, }, diff --git a/packages/nodejs/src/infrastructures/mysql/models/adminusers.ts b/packages/nodejs/src/infrastructures/mysql/models/adminusers.ts index b99af027a..65598e89c 100644 --- a/packages/nodejs/src/infrastructures/mysql/models/adminusers.ts +++ b/packages/nodejs/src/infrastructures/mysql/models/adminusers.ts @@ -69,6 +69,10 @@ const schemaDefinition: ModelAttributes = { type: DataTypes.BIGINT, allowNull: true, }, + oidcRefreshToken: { + type: DataTypes.STRING, + allowNull: true, + }, oidcIdToken: { type: DataTypes.STRING(2048), allowNull: true, From 19594e136e8ad5db0812bf3945449904a79422d0 Mon Sep 17 00:00:00 2001 From: takoring Date: Fri, 17 Jan 2025 20:10:14 +0900 Subject: [PATCH 05/25] fix(nodejs): the flow for OIDC is the same as OAuth --- example/nodejs/src/config/development.ts | 2 +- example/nodejs/src/config/local.ts | 6 +- example/nodejs/src/config/production.ts | 2 +- example/nodejs/src/controllers/auth.ts | 35 ++--- example/nodejs/src/controllers/authconfigs.ts | 35 +++-- packages/app/src/constants/index.ts | 2 +- packages/app/src/hooks/endpoint.ts | 99 +++++++++++++ .../src/pages/oidcredirect/_/appBar/index.tsx | 35 +++++ .../src/pages/oidcredirect/_/body/index.tsx | 133 ++++++++++++++++++ .../pages/oidcredirect/_/navigation/index.tsx | 56 ++++++++ packages/app/src/pages/oidcredirect/index.tsx | 53 +++++++ packages/app/src/types/index.ts | 2 +- packages/nodejs/src/constants.ts | 4 + packages/nodejs/src/domains/auth/oidc.ts | 20 +-- packages/nodejs/src/helpers/cookies.ts | 13 ++ packages/nodejs/src/openapi/auth.yaml | 37 +++-- 16 files changed, 481 insertions(+), 53 deletions(-) create mode 100644 packages/app/src/pages/oidcredirect/_/appBar/index.tsx create mode 100644 packages/app/src/pages/oidcredirect/_/body/index.tsx create mode 100644 packages/app/src/pages/oidcredirect/_/navigation/index.tsx create mode 100644 packages/app/src/pages/oidcredirect/index.tsx diff --git a/example/nodejs/src/config/development.ts b/example/nodejs/src/config/development.ts index f2e12d505..3df0c3728 100644 --- a/example/nodejs/src/config/development.ts +++ b/example/nodejs/src/config/development.ts @@ -50,7 +50,7 @@ export const get = (): Config => { oidc: { clientId: process.env.OIDC_CLIENT_ID ?? '', clientSecret: process.env.OIDC_CLIENT_SECRET ?? '', - callbackUrl: 'https://example.viron.work:3000/oidc/callback', + callbackUrl: 'https://viron.work:8000/oidcredirect', configurationUrl: 'https://federation.perman.jp/.well-known/openid-configuration', additionalScopes: [], diff --git a/example/nodejs/src/config/local.ts b/example/nodejs/src/config/local.ts index 8601f587f..72e8b4c7e 100644 --- a/example/nodejs/src/config/local.ts +++ b/example/nodejs/src/config/local.ts @@ -62,10 +62,10 @@ export const get = (mode: Mode): Config => { oidc: { clientId: process.env.OIDC_CLIENT_ID ?? '', clientSecret: process.env.OIDC_CLIENT_SECRET ?? '', - callbackUrl: 'https://example.viron.work:3000/oidc/callback', + callbackUrl: 'https://viron.work:8000/oidcredirect', configurationUrl: - 'https://cognito-idp.ap-northeast-1.amazonaws.com/ap-northeast-1_GYHXqf9zx', - additionalScopes: ['phone'], + 'https://federation.perman.jp/.well-known/openid-configuration', + additionalScopes: [], userHostedDomains: ['cam-inc.co.jp', 'cyberagent.co.jp'], }, }, diff --git a/example/nodejs/src/config/production.ts b/example/nodejs/src/config/production.ts index 4450eb4c8..cb4683969 100644 --- a/example/nodejs/src/config/production.ts +++ b/example/nodejs/src/config/production.ts @@ -47,7 +47,7 @@ export const get = (): Config => { oidc: { clientId: process.env.OIDC_CLIENT_ID ?? '', clientSecret: process.env.OIDC_CLIENT_SECRET ?? '', - callbackUrl: 'https://example.viron.work:3000/oidc/callback', + callbackUrl: 'https://viron.work:8000/oidcredirect', configurationUrl: 'https://federation.perman.jp/.well-known/openid-configuration', additionalScopes: [], diff --git a/example/nodejs/src/controllers/auth.ts b/example/nodejs/src/controllers/auth.ts index ab6f6c362..62602c6dd 100644 --- a/example/nodejs/src/controllers/auth.ts +++ b/example/nodejs/src/controllers/auth.ts @@ -3,6 +3,7 @@ import { genAuthorizationCookie, genOAuthStateCookie, genOidcStateCookie, + genOidcCodeVerifierCookie, mismatchState, COOKIE_KEY, HTTP_HEADER, @@ -34,6 +35,7 @@ export const oidcAuthorization = async ( context: RouteContext ): Promise => { const { redirectUri } = context.params.query; + const state = domainsAuth.genState(); const client = await domainsAuth.getOidcClient( redirectUri, ctx.config.auth.oidc @@ -44,34 +46,38 @@ export const oidcAuthorization = async ( ctx.config.auth.oidc, client, codeVerifier, + state, ); console.log('codeVerifier:', codeVerifier); - context.res.setHeader( - HTTP_HEADER.SET_COOKIE, - genOidcStateCookie(codeVerifier, {partitioned: true, sameSite: 'none', secure: true, httpOnly: true}) - ); + const cookies = [ + genOidcStateCookie(state), + genOidcCodeVerifierCookie(codeVerifier) + ]; + context.res.setHeader(HTTP_HEADER.SET_COOKIE, cookies); context.res.setHeader(HTTP_HEADER.LOCATION, authorizationUrl); context.res.status(301).end(); }; // OIDCのコールバック export const oidcCallback = async (context: RouteContext): Promise => { + const codeVerifier = context.req.cookies[COOKIE_KEY.OIDC_CODE_VERIFIER]; const cookieState = context.req.cookies[COOKIE_KEY.OIDC_STATE]; - // const { code } = context.params.query; + const { state, redirectUri } = context.requestBody; - // if (!cookieState || !state || cookieState !== state) { - // throw mismatchState(); - // } + if (!codeVerifier || !cookieState || !state || cookieState !== state) { + throw mismatchState(); + } console.log('cookieState:', cookieState); - const client = await domainsAuth.getOidcClient('', ctx.config.auth.oidc); + const client = await domainsAuth.getOidcClient(redirectUri, ctx.config.auth.oidc); + const params = client.callbackParams(context.req); const token = await domainsAuth.signinOidc( client, - cookieState!, - context.req, + codeVerifier!, + params, ctx.config.auth.oidc ); context.res.setHeader( @@ -80,11 +86,8 @@ export const oidcCallback = async (context: RouteContext): Promise => { maxAge: ctx.config.auth.jwt.expirationSec, }) ); - context.res.setHeader( - HTTP_HEADER.LOCATION, - 'https://viron.work:8000/ja/endpoints/example/' - ); - context.res.status(301).end(); + + context.res.status(204).end(); }; // GoogleOAuth2の認可画面へリダイレクト diff --git a/example/nodejs/src/controllers/authconfigs.ts b/example/nodejs/src/controllers/authconfigs.ts index 8846a0fe2..e2c60d7ca 100644 --- a/example/nodejs/src/controllers/authconfigs.ts +++ b/example/nodejs/src/controllers/authconfigs.ts @@ -5,9 +5,10 @@ import { AUTH_CONFIG_PROVIDER, // EMAIL_SIGNIN_PATH, SIGNOUT_PATH, - // OAUTH2_GOOGLE_AUTHORIZATION_PATH, - // OAUTH2_GOOGLE_CALLBACK_PATH, + OAUTH2_GOOGLE_AUTHORIZATION_PATH, + OAUTH2_GOOGLE_CALLBACK_PATH, OIDC_AUTHORIZATION_PATH, + OIDC_CALLBACK_PATH, } from '@viron/lib'; import { RouteContext } from '../application'; @@ -24,24 +25,30 @@ export const listVironAuthconfigs = async ( // method: API_METHOD.POST, // path: EMAIL_SIGNIN_PATH, // }, - // { - // provider: AUTH_CONFIG_PROVIDER.GOOGLE, - // type: AUTH_CONFIG_TYPE.OAUTH, - // method: API_METHOD.GET, - // path: OAUTH2_GOOGLE_AUTHORIZATION_PATH, - // }, - // { - // provider: AUTH_CONFIG_PROVIDER.GOOGLE, - // type: AUTH_CONFIG_TYPE.OAUTH_CALLBACK, - // method: API_METHOD.POST, - // path: OAUTH2_GOOGLE_CALLBACK_PATH, - // }, { provider: AUTH_CONFIG_PROVIDER.GOOGLE, + type: AUTH_CONFIG_TYPE.OAUTH, + method: API_METHOD.GET, + path: OAUTH2_GOOGLE_AUTHORIZATION_PATH, + }, + { + provider: AUTH_CONFIG_PROVIDER.GOOGLE, + type: AUTH_CONFIG_TYPE.OAUTH_CALLBACK, + method: API_METHOD.POST, + path: OAUTH2_GOOGLE_CALLBACK_PATH, + }, + { + provider: AUTH_CONFIG_PROVIDER.OIDC, type: AUTH_CONFIG_TYPE.OIDC, method: API_METHOD.GET, path: OIDC_AUTHORIZATION_PATH, }, + { + provider: AUTH_CONFIG_PROVIDER.OIDC, + type: AUTH_CONFIG_TYPE.OIDC_CALLBACK, + method: API_METHOD.POST, + path: OIDC_CALLBACK_PATH, + }, { provider: AUTH_CONFIG_PROVIDER.SIGNOUT, type: AUTH_CONFIG_TYPE.SIGNOUT, diff --git a/packages/app/src/constants/index.ts b/packages/app/src/constants/index.ts index b31563c7c..35f651e6e 100644 --- a/packages/app/src/constants/index.ts +++ b/packages/app/src/constants/index.ts @@ -41,7 +41,7 @@ export const OIDC_REDIRECT_URI = (function () { if (isSSR) { return ''; } - return `https://example.viron.work:3000/oidc/callback`; + return `${globalThis.location.origin}/oidcredirect`; })(); export const HTTP_STATUS = { diff --git a/packages/app/src/hooks/endpoint.ts b/packages/app/src/hooks/endpoint.ts index 7688e9cd3..468db37fe 100644 --- a/packages/app/src/hooks/endpoint.ts +++ b/packages/app/src/hooks/endpoint.ts @@ -158,6 +158,29 @@ export type UseEndpointReturn = { } >; }; + prepareSigninOidcCallback: ( + endpoint: Endpoint, + authentication: Authentication, + defaultValues?: RequestValue + ) => + | { + error: BaseError; + } + | { + error: null; + endpoint: Endpoint; + document: Document; + request: Request; + defaultValues: RequestValue; + execute: (requestValue: RequestValue) => Promise< + | { + error: BaseError; + } + | { + error: null; + } + >; + }; prepareSignout: ( endpoint: Endpoint, authentication: Authentication, @@ -723,6 +746,80 @@ export const useEndpoint = (): UseEndpointReturn => { }; }, []); + const prepareSigninOidcCallback = useCallback< + UseEndpointReturn['prepareSigninOidcCallback'] + >((endpoint, authentication, defaultValues = {}) => { + const authConfig = authentication.list.find( + (item) => item.type === 'oidccallback' + ); + if (!authConfig) { + return { + error: new BaseError('AuthConfig for OIDC callback not found.'), + }; + } + const getRequestResult = extractRequest( + authentication.oas, + authConfig.operationId + ); + if (getRequestResult.isFailure()) { + return { + error: new OASError('Request object not found.'), + }; + } + const request = getRequestResult.value; + defaultValues = _.merge( + {}, + { + parameters: authConfig.defaultParametersValue, + requestBody: replaceWithEnvironmentalVariables( + authConfig.defaultRequestBodyValue || {}, + { + [ENVIRONMENTAL_VARIABLE.OIDC_REDIRECT_URI]: OIDC_REDIRECT_URI, + } + ), + }, + cleanupRequestValue(request, defaultValues) + ); + const execute = async (requestValue: RequestValue) => { + const requestPayloads = constructRequestPayloads( + request.operation, + requestValue + ); + const requestInfo = constructRequestInfo( + endpoint, + authentication.oas, + request, + requestPayloads + ); + const requestInit = constructRequestInit(request, requestPayloads); + const [response, responseError] = await promiseErrorHandler( + globalThis.fetch(requestInfo, requestInit) + ); + if (!!responseError) { + return { + error: new NetworkError(responseError.message), + }; + } + if (!response.ok) { + return { + error: await getHTTPError(response), + }; + } + return { + error: null, + }; + }; + return { + error: null, + endpoint, + document: authentication.oas, + request, + defaultValues, + execute, + }; + }, []); + + const prepareSignout = useCallback( (endpoint, authentication, defaultValues = {}) => { const authConfig = authentication.list.find( @@ -1030,6 +1127,7 @@ export const useEndpoint = (): UseEndpointReturn => { navigate, prepareSigninEmail, prepareSigninOidc, + prepareSigninOidcCallback, prepareSigninOAuth, prepareSigninOAuthCallback, prepareSignout, @@ -1054,6 +1152,7 @@ export const useEndpoint = (): UseEndpointReturn => { navigate, prepareSigninEmail, prepareSigninOidc, + prepareSigninOidcCallback, prepareSigninOAuth, prepareSigninOAuthCallback, prepareSignout, diff --git a/packages/app/src/pages/oidcredirect/_/appBar/index.tsx b/packages/app/src/pages/oidcredirect/_/appBar/index.tsx new file mode 100644 index 000000000..d9affd1f5 --- /dev/null +++ b/packages/app/src/pages/oidcredirect/_/appBar/index.tsx @@ -0,0 +1,35 @@ +import React, { useCallback } from 'react'; +import { SIZE as BUTTON_SIZE } from '~/components/button'; +import Button from '~/components/button'; +import MenuAlt1Icon from '~/components/icon/menuAlt1/outline'; +import { Props as LayoutProps } from '~/layouts'; +import { useAppScreenGlobalStateValue } from '~/store'; +import { COLOR_SYSTEM } from '~/types'; + +type Props = Parameters>[0]; +const AppBar: React.FC = ({ className = '', openNavigation }) => { + const { lg } = useAppScreenGlobalStateValue(); + const handleNavButtonClick = useCallback(() => { + openNavigation(); + }, [openNavigation]); + + return ( +
+
+
+ {!lg && ( +
+
+
+
+ ); +}; +export default AppBar; diff --git a/packages/app/src/pages/oidcredirect/_/body/index.tsx b/packages/app/src/pages/oidcredirect/_/body/index.tsx new file mode 100644 index 000000000..d0eaa8d88 --- /dev/null +++ b/packages/app/src/pages/oidcredirect/_/body/index.tsx @@ -0,0 +1,133 @@ +import classnames from 'classnames'; +import { parse } from 'query-string'; +import React, { useCallback, useEffect, useState } from 'react'; +import Error, { useError } from '~/components/error'; +import Request from '~/components/request'; +import Spinner from '~/components/spinner'; +import { BaseError } from '~/errors'; +import { useEndpoint, UseEndpointReturn } from '~/hooks/endpoint'; +import { Props as LayoutProps } from '~/layouts'; +import { KEY, get } from '~/storage'; +import { useEndpointListItemGlobalStateValue } from '~/store'; +import { COLOR_SYSTEM, EndpointID } from '~/types'; +import { RequestValue } from '~/types/oas'; + +export type Props = Parameters[0] & { + search: string; +}; +const Body: React.FC = ({ className = '', search }) => { + const error = useError({ on: COLOR_SYSTEM.SURFACE, withModal: true }); + const setError = error.setError; + const [isPending, setIsPending] = useState(true); + const endpoint = useEndpointListItemGlobalStateValue({ + id: get(KEY.OIDC_ENDPOINT_ID), + }); + const { prepareSigninOidcCallback, connect, fetchDocument, navigate } = + useEndpoint(); + const [signinOidcCallback, setSigninOidcCallback] = useState | null>(null); + + useEffect(() => { + setError(null); + setIsPending(true); + if (!endpoint) { + setError(new BaseError('Endpoint Not Found.')); + setIsPending(false); + return; + } + const f = async () => { + const connection = await connect(endpoint.url); + if (connection.error) { + setError(connection.error); + setIsPending(false); + return; + } + const fetchDocumentResult = await fetchDocument(endpoint); + if (fetchDocumentResult.error) { + setError(fetchDocumentResult.error); + setIsPending(false); + return; + } + const { authentication } = fetchDocumentResult; + const queries = parse(search); + const signinOidcCallback = prepareSigninOidcCallback( + endpoint, + authentication, + { + parameters: queries, + requestBody: queries, + } + ); + setSigninOidcCallback(signinOidcCallback); + setIsPending(false); + }; + f(); + }, [ + endpoint, + connect, + fetchDocument, + setError, + search, + prepareSigninOidcCallback, + ]); + + const handleSubmit = useCallback( + async (requestValue: RequestValue) => { + if (!endpoint) { + return; + } + if (!signinOidcCallback) { + return; + } + if (signinOidcCallback.error) { + return; + } + const result = await signinOidcCallback.execute(requestValue); + if (result.error) { + error.setError(result.error); + return; + } + navigate(endpoint); + }, + [endpoint, error, navigate, signinOidcCallback] + ); + + if (isPending) { + return ( +
+ +
+ ); + } + if (!signinOidcCallback) { + return null; + } + if (signinOidcCallback.error) { + return ( +
+ +
+ ); + } + + return ( +
+ + +
+ ); +}; +export default Body; diff --git a/packages/app/src/pages/oidcredirect/_/navigation/index.tsx b/packages/app/src/pages/oidcredirect/_/navigation/index.tsx new file mode 100644 index 000000000..a807f45e5 --- /dev/null +++ b/packages/app/src/pages/oidcredirect/_/navigation/index.tsx @@ -0,0 +1,56 @@ +import classnames from 'classnames'; +import React, { useCallback } from 'react'; +import Logo from '~/components/logo'; +import Navigation, { Props as NavigationProps } from '~/components/navigation'; +import NavigationLinks from '~/components/navigation/links'; +import NavigationServices from '~/components/navigation/services'; +import NavigationVersion from '~/components/navigation/version'; +import { Props as LayoutProps } from '~/layouts'; +import { COLOR_SYSTEM } from '~/types'; + +type Props = Parameters>[0]; +const _Navigation: React.FC = ({ className }) => { + const renderHead = useCallback>( + () => ( +
+ +
+ Give OAS,
+ Get GUI. +
+
+ ), + [] + ); + + const renderTail = useCallback>( + () => ( +
+
+ +
+
+ +
+
+ +
+
+ ), + [] + ); + + return ( + + ); +}; +export default _Navigation; diff --git a/packages/app/src/pages/oidcredirect/index.tsx b/packages/app/src/pages/oidcredirect/index.tsx new file mode 100644 index 000000000..e0d3ad3c8 --- /dev/null +++ b/packages/app/src/pages/oidcredirect/index.tsx @@ -0,0 +1,53 @@ +import { PageProps, graphql } from 'gatsby'; +import React, { useCallback } from 'react'; +import Metadata from '~/components/metadata'; +import useTheme from '~/hooks/theme'; +import Layout, { Props as LayoutProps } from '~/layouts'; +import AppBar from './_/appBar'; +import Body from './_/body'; +import Navigation from './_/navigation'; + +type Props = PageProps; +const OidcRedirectPage: React.FC = ({ location }) => { + useTheme(); + + const renderAppBar = useCallback>( + (args) => , + [] + ); + + const renderNavigation = useCallback< + NonNullable + >((args) => , []); + + const renderBody = useCallback( + (args) => , + [location.search] + ); + + return ( + <> + + + + ); +}; +export default OidcRedirectPage; + +export const query = graphql` + query ($language: String!) { + locales: allLocale(filter: { language: { eq: $language } }) { + edges { + node { + ns + data + language + } + } + } + } +`; diff --git a/packages/app/src/types/index.ts b/packages/app/src/types/index.ts index efd45b548..48b5919be 100644 --- a/packages/app/src/types/index.ts +++ b/packages/app/src/types/index.ts @@ -54,7 +54,7 @@ export type Authentication = { }; export type AuthConfig = { - type: 'email' | 'oauth' | 'oauthcallback' | 'signout' | 'oidc'; + type: 'email' | 'oauth' | 'oauthcallback' | 'signout' | 'oidc' | 'oidccallback'; provider: 'viron' | 'google' | 'signout'; operationId: OperationId; mode?: 'navigate' | 'cors'; diff --git a/packages/nodejs/src/constants.ts b/packages/nodejs/src/constants.ts index 601edaf90..bb178e244 100644 --- a/packages/nodejs/src/constants.ts +++ b/packages/nodejs/src/constants.ts @@ -12,6 +12,7 @@ export const AUTH_CONFIG_TYPE = { OAUTH: 'oauth', OIDC: 'oidc', OAUTH_CALLBACK: 'oauthcallback', + OIDC_CALLBACK: 'oidccallback', SIGNOUT: 'signout', } as const; export type AuthConfigType = @@ -20,6 +21,7 @@ export type AuthConfigType = export const AUTH_CONFIG_PROVIDER = { VIRON: 'viron', GOOGLE: 'google', + OIDC: 'oidc', SIGNOUT: 'signout', } as const; export type AuthConfigProvider = @@ -86,6 +88,7 @@ export const EMAIL_SIGNIN_PATH = '/email/signin'; export const OAUTH2_GOOGLE_AUTHORIZATION_PATH = '/oauth2/google/authorization'; export const OAUTH2_GOOGLE_CALLBACK_PATH = '/oauth2/google/callback'; export const OIDC_AUTHORIZATION_PATH = '/oidc/authorization'; +export const OIDC_CALLBACK_PATH = '/oidc/callback'; export const SIGNOUT_PATH = '/signout'; export const PERMISSION = { @@ -130,6 +133,7 @@ export const COOKIE_KEY = { VIRON_AUTHORIZATION: 'viron_authorization', OAUTH2_STATE: 'oauth2_state', OIDC_STATE: 'oidc_state', + OIDC_CODE_VERIFIER: 'oidc_code_verifier', } as const; export const GOOGLE_OAUTH2_DEFAULT_SCOPES = [ diff --git a/packages/nodejs/src/domains/auth/oidc.ts b/packages/nodejs/src/domains/auth/oidc.ts index b91dc95c9..7aacd26e6 100644 --- a/packages/nodejs/src/domains/auth/oidc.ts +++ b/packages/nodejs/src/domains/auth/oidc.ts @@ -1,4 +1,4 @@ -import { Issuer, generators, Client, TokenSet } from 'openid-client'; +import { Issuer, generators, Client, TokenSet, CallbackParamsType } from 'openid-client'; import { getDebug } from '../../logging'; import { signJwt } from './jwt'; const debug = getDebug('domains:auth:oidc'); @@ -27,7 +27,7 @@ export interface OidcConfig extends OidcClientConfig { let oidcClient: Client; export const getOidcClient = async ( - redirecturl: string, + redirectUrl: string, config: OidcConfig ): Promise => { if (oidcClient) { @@ -53,13 +53,13 @@ export const getOidcClient = async ( throw new Error('client.issuer.metadata.scopes_supported is not found'); } - console.log('redirecturl %s', redirecturl); + console.log('redirectUrl %s', redirectUrl); // クライアントの作成 oidcClient = new issuer.Client({ client_id: config.clientId, client_secret: config.clientSecret, - redirect_uris: [redirecturl], + redirect_uris: [redirectUrl], response_types: ['code'], }); @@ -75,7 +75,8 @@ export const genOidcCodeVerifier = async (): Promise => { export const getOidcAuthorizationUrl = async ( oidcConfig: OidcConfig, client: Client, - codeVerifier: string + codeVerifier: string, + state: string ): Promise => { // PKCE用のコードベリファイアを生成 @@ -90,6 +91,7 @@ export const getOidcAuthorizationUrl = async ( : OIDC_DEFAULT_SCOPES.join(' '), code_challenge: codeChallenge, code_challenge_method: 'S256', + state, }); console.log('Authorization URL:', authorizationUrl); @@ -101,15 +103,17 @@ export const getOidcAuthorizationUrl = async ( export const signinOidc = async ( client: Client, codeVerifier: string, - req: any, + params: CallbackParamsType, oidcConfig: OidcConfig ): Promise => { - const params = client.callbackParams(req); - // params.state = req.cookies['oidc_state']; + console.log('params:', params); + console.log('codeVerifier:', codeVerifier); + console.log('oidcConfig.callbackUrl:', oidcConfig.callbackUrl); const tokenSet = await client.callback(oidcConfig.callbackUrl, params, { code_verifier: codeVerifier, + state: params.state, }); const claims = tokenSet.claims(); diff --git a/packages/nodejs/src/helpers/cookies.ts b/packages/nodejs/src/helpers/cookies.ts index 4435602a3..5b114ce78 100644 --- a/packages/nodejs/src/helpers/cookies.ts +++ b/packages/nodejs/src/helpers/cookies.ts @@ -67,3 +67,16 @@ export const genOidcStateCookie = ( } return genCookie(COOKIE_KEY.OIDC_STATE, state, opts); }; + +// OIDC PKCE用CodeVerifierのCookie文字列を生成 +export const genOidcCodeVerifierCookie = ( + codeVerifier: string, + options?: CookieSerializeOptions +): string => { + const opts = Object.assign({}, options); + if (!opts.maxAge && !opts.expires) { + opts.maxAge = OIDC_STATE_EXPIRATION_SEC; + } + return genCookie(COOKIE_KEY.OIDC_CODE_VERIFIER, codeVerifier, opts); +}; + diff --git a/packages/nodejs/src/openapi/auth.yaml b/packages/nodejs/src/openapi/auth.yaml index 131010666..30e2b58fb 100644 --- a/packages/nodejs/src/openapi/auth.yaml +++ b/packages/nodejs/src/openapi/auth.yaml @@ -52,18 +52,22 @@ paths: redirectUri: '${oidcRedirectURI}' /oidc/callback: - get: + post: operationId: oidcCallback tags: - auth - summary: callback to oidc idp authorization - description: OIDCのidpからのコールバックを受ける - parameters: - - $ref: '#/components/parameters/CodeQueryParam' + summary: callback from oidc + description: OIDC認可後のコールバックURL + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/OidcCallbackPayload' responses: - 301: - description: Redirect to OIDC Authorization URL. - x-authconfig-default-parameters: + 204: + description: No Content. + x-authconfig-default-requestBody: redirectUri: '${oidcRedirectURI}' /oauth2/google/authorization: @@ -149,3 +153,20 @@ components: - code - state - redirectUri + + OidcCallbackPayload: + type: object + properties: + code: + description: OIDC Idpが発行した認可コード + type: string + state: + description: CSRF対策用のステートパラメータ + type: string + redirectUri: + description: OIDCコールバックURI + type: string + required: + - code + - state + - redirectUri From 6999fb61cc65104d9e5f8aa8743edb4ade951ea8 Mon Sep 17 00:00:00 2001 From: takoring Date: Mon, 20 Jan 2025 13:48:24 +0900 Subject: [PATCH 06/25] fix(nodejs): brushing up code for oidc --- docker-compose.example-nodejs.mongo.yml | 42 +++++++-------- example/nodejs/package.json | 1 - example/nodejs/src/config/development.ts | 4 +- example/nodejs/src/config/local.ts | 6 +-- example/nodejs/src/config/production.ts | 4 +- example/nodejs/src/controllers/auth.ts | 13 ++--- example/nodejs/src/controllers/authconfigs.ts | 14 ++--- example/nodejs/src/security_handlers/jwt.ts | 2 +- packages/app/package.json | 2 +- packages/nodejs/src/domains/adminuser.ts | 2 + packages/nodejs/src/domains/auth/oidc.ts | 54 +++++++++---------- 11 files changed, 70 insertions(+), 74 deletions(-) diff --git a/docker-compose.example-nodejs.mongo.yml b/docker-compose.example-nodejs.mongo.yml index 6f69b1aaf..3161bbb51 100644 --- a/docker-compose.example-nodejs.mongo.yml +++ b/docker-compose.example-nodejs.mongo.yml @@ -2,27 +2,27 @@ version: '3.8' services: - # example: - # build: - # context: '.' - # dockerfile: Dockerfile.example-nodejs - # restart: always - # depends_on: - # - mongo - # ports: - # - 3000:3000 - # - 9229:9229 # node-inspector - # environment: - # - MODE=mongo - # env_file: - # - $PWD/example/nodejs/.env - # volumes: - # - $PWD/example/nodejs/package.json:/viron/example/nodejs/package.json - # - $PWD/example/nodejs/src:/viron/example/nodejs/src - # - $PWD/example/nodejs/cert:/viron/example/nodejs/cert - # - $PWD/packages/nodejs:/viron/packages/nodejs - # - $PWD/packages/linter:/viron/packages/linter - # command: npm run dev + example: + build: + context: '.' + dockerfile: Dockerfile.example-nodejs + restart: always + depends_on: + - mongo + ports: + - 3000:3000 + - 9229:9229 # node-inspector + environment: + - MODE=mongo + env_file: + - $PWD/example/nodejs/.env + volumes: + - $PWD/example/nodejs/package.json:/viron/example/nodejs/package.json + - $PWD/example/nodejs/src:/viron/example/nodejs/src + - $PWD/example/nodejs/cert:/viron/example/nodejs/cert + - $PWD/packages/nodejs:/viron/packages/nodejs + - $PWD/packages/linter:/viron/packages/linter + command: npm run dev mongo: extends: diff --git a/example/nodejs/package.json b/example/nodejs/package.json index fc770c548..bceeecc97 100644 --- a/example/nodejs/package.json +++ b/example/nodejs/package.json @@ -11,7 +11,6 @@ "cookie-parser": "^1.4.5", "cors": "^2.8.5", "cross-env": "^7.0.3", - "dotenv-cli": "^8.0.0", "exegesis-express": "^2.0.1", "express": "^4.19.2", "i18next": "^21.6.7", diff --git a/example/nodejs/src/config/development.ts b/example/nodejs/src/config/development.ts index 3df0c3728..cfddb1668 100644 --- a/example/nodejs/src/config/development.ts +++ b/example/nodejs/src/config/development.ts @@ -50,9 +50,9 @@ export const get = (): Config => { oidc: { clientId: process.env.OIDC_CLIENT_ID ?? '', clientSecret: process.env.OIDC_CLIENT_SECRET ?? '', - callbackUrl: 'https://viron.work:8000/oidcredirect', + callbackUrl: 'https://localhost:8000/oidcredirect', configurationUrl: - 'https://federation.perman.jp/.well-known/openid-configuration', + 'https://{oidc idp host}/.well-known/openid-configuration', additionalScopes: [], userHostedDomains: ['cam-inc.co.jp', 'cyberagent.co.jp'], }, diff --git a/example/nodejs/src/config/local.ts b/example/nodejs/src/config/local.ts index 72e8b4c7e..19980019b 100644 --- a/example/nodejs/src/config/local.ts +++ b/example/nodejs/src/config/local.ts @@ -8,7 +8,7 @@ import { Mode, MODE } from '../constants'; export const get = (mode: Mode): Config => { const mongo: MongoConfig = { type: 'mongo', - openUri: 'mongodb://0.0.0.0:27017', + openUri: 'mongodb://mongo:27017', connectOptions: { // MongoDB Options dbName: 'viron_example', @@ -62,9 +62,9 @@ export const get = (mode: Mode): Config => { oidc: { clientId: process.env.OIDC_CLIENT_ID ?? '', clientSecret: process.env.OIDC_CLIENT_SECRET ?? '', - callbackUrl: 'https://viron.work:8000/oidcredirect', + callbackUrl: 'https://localhost:8000/oidcredirect', configurationUrl: - 'https://federation.perman.jp/.well-known/openid-configuration', + 'https://{oidc idp host}/.well-known/openid-configuration', additionalScopes: [], userHostedDomains: ['cam-inc.co.jp', 'cyberagent.co.jp'], }, diff --git a/example/nodejs/src/config/production.ts b/example/nodejs/src/config/production.ts index cb4683969..2dda40ac8 100644 --- a/example/nodejs/src/config/production.ts +++ b/example/nodejs/src/config/production.ts @@ -47,9 +47,9 @@ export const get = (): Config => { oidc: { clientId: process.env.OIDC_CLIENT_ID ?? '', clientSecret: process.env.OIDC_CLIENT_SECRET ?? '', - callbackUrl: 'https://viron.work:8000/oidcredirect', + callbackUrl: 'https://localhost:8000/oidcredirect', configurationUrl: - 'https://federation.perman.jp/.well-known/openid-configuration', + 'https://{oidc idp host}/.well-known/openid-configuration', additionalScopes: [], userHostedDomains: ['cam-inc.co.jp', 'cyberagent.co.jp'], }, diff --git a/example/nodejs/src/controllers/auth.ts b/example/nodejs/src/controllers/auth.ts index 62602c6dd..b183134c9 100644 --- a/example/nodejs/src/controllers/auth.ts +++ b/example/nodejs/src/controllers/auth.ts @@ -36,12 +36,15 @@ export const oidcAuthorization = async ( ): Promise => { const { redirectUri } = context.params.query; const state = domainsAuth.genState(); - const client = await domainsAuth.getOidcClient( + const client = await domainsAuth.genOidcClient( redirectUri, ctx.config.auth.oidc ); + + // PKCE用のCodeVerifierを生成 const codeVerifier = await domainsAuth.genOidcCodeVerifier(); + // OIDC認証画面URLを取得 const authorizationUrl = await domainsAuth.getOidcAuthorizationUrl( ctx.config.auth.oidc, client, @@ -49,8 +52,7 @@ export const oidcAuthorization = async ( state, ); - console.log('codeVerifier:', codeVerifier); - + // CookieにOIDCのStateとPKCE用のCodeVerifierをセット const cookies = [ genOidcStateCookie(state), genOidcCodeVerifierCookie(codeVerifier) @@ -70,9 +72,8 @@ export const oidcCallback = async (context: RouteContext): Promise => { throw mismatchState(); } - console.log('cookieState:', cookieState); - - const client = await domainsAuth.getOidcClient(redirectUri, ctx.config.auth.oidc); + // OIDC Clientを取得 + const client = await domainsAuth.genOidcClient(redirectUri, ctx.config.auth.oidc); const params = client.callbackParams(context.req); const token = await domainsAuth.signinOidc( client, diff --git a/example/nodejs/src/controllers/authconfigs.ts b/example/nodejs/src/controllers/authconfigs.ts index e2c60d7ca..08b430898 100644 --- a/example/nodejs/src/controllers/authconfigs.ts +++ b/example/nodejs/src/controllers/authconfigs.ts @@ -3,7 +3,7 @@ import { API_METHOD, AUTH_CONFIG_TYPE, AUTH_CONFIG_PROVIDER, - // EMAIL_SIGNIN_PATH, + EMAIL_SIGNIN_PATH, SIGNOUT_PATH, OAUTH2_GOOGLE_AUTHORIZATION_PATH, OAUTH2_GOOGLE_CALLBACK_PATH, @@ -19,12 +19,12 @@ export const listVironAuthconfigs = async ( context: RouteContext ): Promise => { const authConfigDefinitions = [ - // { - // provider: AUTH_CONFIG_PROVIDER.VIRON, - // type: AUTH_CONFIG_TYPE.EMAIL, - // method: API_METHOD.POST, - // path: EMAIL_SIGNIN_PATH, - // }, + { + provider: AUTH_CONFIG_PROVIDER.VIRON, + type: AUTH_CONFIG_TYPE.EMAIL, + method: API_METHOD.POST, + path: EMAIL_SIGNIN_PATH, + }, { provider: AUTH_CONFIG_PROVIDER.GOOGLE, type: AUTH_CONFIG_TYPE.OAUTH, diff --git a/example/nodejs/src/security_handlers/jwt.ts b/example/nodejs/src/security_handlers/jwt.ts index e3389dd90..688ef5475 100644 --- a/example/nodejs/src/security_handlers/jwt.ts +++ b/example/nodejs/src/security_handlers/jwt.ts @@ -91,7 +91,7 @@ export const jwt = async ( } case AUTH_TYPE.OIDC: { // OIDC認証の場合はアクセストークンの検証 - const client = await domainsAuth.getOidcClient('', ctx.config.auth.oidc) + const client = await domainsAuth.genOidcClient(ctx.config.auth.oidc.callbackUrl, ctx.config.auth.oidc) if ( await domainsAuth.verifyOidcAccessToken( client, diff --git a/packages/app/package.json b/packages/app/package.json index b05d8a440..b754127b3 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -14,7 +14,7 @@ "pre-commit": "run-s lint:staged test:static test:unit", "pre-push": "npm run ci:local", "preflight": "npm install --legacy-peer-deps", - "develop": "gatsby develop --https -p 8000 --cert-file ./cert/viron.crt --key-file ./cert/viron.key", + "develop": "gatsby develop --https -p 8000", "build": "gatsby build", "build:prefix": "gatsby build --prefix-paths", "serve": "gatsby serve -p 9000", diff --git a/packages/nodejs/src/domains/adminuser.ts b/packages/nodejs/src/domains/adminuser.ts index abc6f5c4d..ac374a83d 100644 --- a/packages/nodejs/src/domains/adminuser.ts +++ b/packages/nodejs/src/domains/adminuser.ts @@ -264,6 +264,8 @@ export const updateOneById = async ( } else if (user.authType === AUTH_TYPE.OIDC) { const adminUserOidc = adminUser as AdminUserOidcUpdatePayload; await repository.updateOneById(id, adminUserOidc); + } else { + throw new Error('Invalid authType'); } if (roleIds?.length) { diff --git a/packages/nodejs/src/domains/auth/oidc.ts b/packages/nodejs/src/domains/auth/oidc.ts index 7aacd26e6..4efe2e712 100644 --- a/packages/nodejs/src/domains/auth/oidc.ts +++ b/packages/nodejs/src/domains/auth/oidc.ts @@ -24,19 +24,14 @@ export interface OidcConfig extends OidcClientConfig { userHostedDomains?: string[]; } -let oidcClient: Client; - -export const getOidcClient = async ( +// OIDCクライアントの生成 +export const genOidcClient = async ( redirectUrl: string, config: OidcConfig ): Promise => { - if (oidcClient) { - return oidcClient; - } - // OIDCプロバイダーのIssuerを取得 const issuer = await Issuer.discover(config.configurationUrl); - console.log('Discovered issuer %o', issuer); + debug('Discovered issuer %o', issuer); // issuer.metadata.scopes_supportedでサポートされていないスコープがないかチェック const scopesSupported = issuer.metadata.scopes_supported as string[]; @@ -49,25 +44,23 @@ export const getOidcClient = async ( } } else { // scopes_supportedが見つからない場合はエラー - console.log('client.issuer.metadata.scopes_supported is not found'); + debug('client.issuer.metadata.scopes_supported is not found'); throw new Error('client.issuer.metadata.scopes_supported is not found'); } - console.log('redirectUrl %s', redirectUrl); + debug('redirectUrl %s', redirectUrl); // クライアントの作成 - oidcClient = new issuer.Client({ + return new issuer.Client({ client_id: config.clientId, client_secret: config.clientSecret, redirect_uris: [redirectUrl], response_types: ['code'], }); - - return oidcClient; }; +// OIDC用のコードベリファイアを生成 export const genOidcCodeVerifier = async (): Promise => { - // PKCE用のコードベリファイアを生成 return generators.codeVerifier(); }; @@ -82,7 +75,7 @@ export const getOidcAuthorizationUrl = async ( // PKCE用のコードベリファイアを生成 const codeChallenge = generators.codeChallenge(codeVerifier); - console.log('clinet issuer metadata %o', client.issuer.metadata.scopes_supported); + debug('clinet issuer metadata %o', client.issuer.metadata.scopes_supported); // 認証URLを生成 const authorizationUrl = client.authorizationUrl({ @@ -94,7 +87,7 @@ export const getOidcAuthorizationUrl = async ( state, }); - console.log('Authorization URL:', authorizationUrl); + debug('Authorization URL:', authorizationUrl); return authorizationUrl; }; @@ -107,9 +100,9 @@ export const signinOidc = async ( oidcConfig: OidcConfig ): Promise => { - console.log('params:', params); - console.log('codeVerifier:', codeVerifier); - console.log('oidcConfig.callbackUrl:', oidcConfig.callbackUrl); + debug('params:', params); + debug('codeVerifier:', codeVerifier); + debug('oidcConfig.callbackUrl:', oidcConfig.callbackUrl); const tokenSet = await client.callback(oidcConfig.callbackUrl, params, { code_verifier: codeVerifier, @@ -117,14 +110,8 @@ export const signinOidc = async ( }); const claims = tokenSet.claims(); - - console.log('Token Set:', tokenSet); - console.log('ID Token Claims:', claims); - const credentials = formatCredentials(tokenSet); - console.log('create credentials ', credentials); - if (!credentials.oidcIdToken) { debug('signinOidc invalid authentication codeVerifier. %s', codeVerifier); throw invalidGoogleOAuth2Token(); @@ -151,11 +138,16 @@ export const signinOidc = async ( throw forbidden(); } + // トークンの有効期限が切れている場合は403 if (tokenSet.expired()) { - console.log('Token expired!'); + debug('Token expired!'); + throw forbidden(); } + // emailでユーザーを検索 let adminUser = await findOneByEmail(email); + + // ユーザーが存在しない場合は新規作成 if (!adminUser) { const firstAdminUser = await createFirstAdminUser( { email, ...credentials }, @@ -168,9 +160,12 @@ export const signinOidc = async ( adminUser = await createOne({ email, ...credentials }, AUTH_TYPE.OIDC); await addRoleForUser(adminUser.id, ADMIN_ROLE.VIEWER); } - } - if (adminUser.authType !== AUTH_TYPE.OIDC) { - throw signinFailed(); + } else { + if (adminUser.authType !== AUTH_TYPE.OIDC) { + throw signinFailed(); + } + // 既存ユーザーの情報を更新 + await updateOneById(adminUser.id, credentials); } debug('signinOidc Sign jwt for user: %s', adminUser.id); @@ -250,7 +245,6 @@ export const verifyOidcAccessToken = async ( // リフレッシュトークンがある場合はリフレッシュトークンを使ってトークンを更新 const tokenset = await client.refresh(credentials.oidcRefreshToken!); - console.log('refresh token set:', tokenset); if (!tokenset) { debug('verifyOidcAccessToken invalid refresh token. %s', credentials.oidcRefreshToken); return false; From 02bc8d4260e06bde77451127fabe92d6fbdbe535 Mon Sep 17 00:00:00 2001 From: takoring Date: Mon, 20 Jan 2025 14:10:13 +0900 Subject: [PATCH 07/25] fix(nodejs): lint and format --- example/nodejs/src/constants.ts | 4 +- example/nodejs/src/controllers/auth.ts | 11 +-- example/nodejs/src/security_handlers/jwt.ts | 13 ++-- packages/app/src/hooks/endpoint.ts | 1 - packages/app/src/types/index.ts | 8 ++- .../__tests__/domains/adminuser.test.ts | 35 +++++++++ packages/nodejs/src/constants.ts | 5 +- packages/nodejs/src/domains/auth/oidc.ts | 72 +++++++++++++------ packages/nodejs/src/helpers/cookies.ts | 1 - 9 files changed, 107 insertions(+), 43 deletions(-) diff --git a/example/nodejs/src/constants.ts b/example/nodejs/src/constants.ts index ac3e3da56..60e83c963 100644 --- a/example/nodejs/src/constants.ts +++ b/example/nodejs/src/constants.ts @@ -2,7 +2,7 @@ export const MODE = { MYSQL: 'mysql', MONGO: 'mongo', } as const; -export type Mode = typeof MODE[keyof typeof MODE]; +export type Mode = (typeof MODE)[keyof typeof MODE]; export type StoreType = Mode; export const SERVICE_ENV = { @@ -10,7 +10,7 @@ export const SERVICE_ENV = { DEVELOPMENT: 'development', PRODUCTION: 'production', }; -export type ServiceEnv = typeof SERVICE_ENV[keyof typeof SERVICE_ENV]; +export type ServiceEnv = (typeof SERVICE_ENV)[keyof typeof SERVICE_ENV]; export const AUTHENTICATION_RESULT_TYPE = { SUCCESS: 'success', diff --git a/example/nodejs/src/controllers/auth.ts b/example/nodejs/src/controllers/auth.ts index b183134c9..fa42424a7 100644 --- a/example/nodejs/src/controllers/auth.ts +++ b/example/nodejs/src/controllers/auth.ts @@ -49,13 +49,13 @@ export const oidcAuthorization = async ( ctx.config.auth.oidc, client, codeVerifier, - state, + state ); // CookieにOIDCのStateとPKCE用のCodeVerifierをセット const cookies = [ genOidcStateCookie(state), - genOidcCodeVerifierCookie(codeVerifier) + genOidcCodeVerifierCookie(codeVerifier), ]; context.res.setHeader(HTTP_HEADER.SET_COOKIE, cookies); context.res.setHeader(HTTP_HEADER.LOCATION, authorizationUrl); @@ -73,11 +73,14 @@ export const oidcCallback = async (context: RouteContext): Promise => { } // OIDC Clientを取得 - const client = await domainsAuth.genOidcClient(redirectUri, ctx.config.auth.oidc); + const client = await domainsAuth.genOidcClient( + redirectUri, + ctx.config.auth.oidc + ); const params = client.callbackParams(context.req); const token = await domainsAuth.signinOidc( client, - codeVerifier!, + codeVerifier as string, params, ctx.config.auth.oidc ); diff --git a/example/nodejs/src/security_handlers/jwt.ts b/example/nodejs/src/security_handlers/jwt.ts index 688ef5475..9f4711f59 100644 --- a/example/nodejs/src/security_handlers/jwt.ts +++ b/example/nodejs/src/security_handlers/jwt.ts @@ -91,14 +91,11 @@ export const jwt = async ( } case AUTH_TYPE.OIDC: { // OIDC認証の場合はアクセストークンの検証 - const client = await domainsAuth.genOidcClient(ctx.config.auth.oidc.callbackUrl, ctx.config.auth.oidc) - if ( - await domainsAuth.verifyOidcAccessToken( - client, - userId, - user, - ) - ) { + const client = await domainsAuth.genOidcClient( + ctx.config.auth.oidc.callbackUrl, + ctx.config.auth.oidc + ); + if (await domainsAuth.verifyOidcAccessToken(client, userId, user)) { return authSuccess(user); } break; diff --git a/packages/app/src/hooks/endpoint.ts b/packages/app/src/hooks/endpoint.ts index 468db37fe..19428076a 100644 --- a/packages/app/src/hooks/endpoint.ts +++ b/packages/app/src/hooks/endpoint.ts @@ -819,7 +819,6 @@ export const useEndpoint = (): UseEndpointReturn => { }; }, []); - const prepareSignout = useCallback( (endpoint, authentication, defaultValues = {}) => { const authConfig = authentication.list.find( diff --git a/packages/app/src/types/index.ts b/packages/app/src/types/index.ts index 48b5919be..eb00c7c28 100644 --- a/packages/app/src/types/index.ts +++ b/packages/app/src/types/index.ts @@ -54,7 +54,13 @@ export type Authentication = { }; export type AuthConfig = { - type: 'email' | 'oauth' | 'oauthcallback' | 'signout' | 'oidc' | 'oidccallback'; + type: + | 'email' + | 'oauth' + | 'oauthcallback' + | 'signout' + | 'oidc' + | 'oidccallback'; provider: 'viron' | 'google' | 'signout'; operationId: OperationId; mode?: 'navigate' | 'cors'; diff --git a/packages/nodejs/__tests__/domains/adminuser.test.ts b/packages/nodejs/__tests__/domains/adminuser.test.ts index 08479d7d0..efaffdda8 100644 --- a/packages/nodejs/__tests__/domains/adminuser.test.ts +++ b/packages/nodejs/__tests__/domains/adminuser.test.ts @@ -55,6 +55,11 @@ describe('domains/adminuser', () => { googleOAuth2IdToken: null, googleOAuth2RefreshToken: null, googleOAuth2TokenType: null, + oidcAccessToken: null, + oidcExpiryDate: null, + oidcIdToken: null, + oidcRefreshToken: null, + oidcTokenType: null, }, ], maxPage: 1, @@ -99,6 +104,11 @@ describe('domains/adminuser', () => { googleOAuth2IdToken: null, googleOAuth2RefreshToken: null, googleOAuth2TokenType: null, + oidcAccessToken: null, + oidcExpiryDate: null, + oidcIdToken: null, + oidcRefreshToken: null, + oidcTokenType: null, }); const result = await createOne(data); @@ -138,6 +148,11 @@ describe('domains/adminuser', () => { googleOAuth2IdToken: null, googleOAuth2RefreshToken: null, googleOAuth2TokenType: null, + oidcAccessToken: null, + oidcExpiryDate: null, + oidcIdToken: null, + oidcRefreshToken: null, + oidcTokenType: null, }); sandbox .stub(domainsAdminrole, 'updateRolesForUser') @@ -172,6 +187,11 @@ describe('domains/adminuser', () => { googleOAuth2IdToken: null, googleOAuth2RefreshToken: null, googleOAuth2TokenType: null, + oidcAccessToken: null, + oidcExpiryDate: null, + oidcIdToken: null, + oidcRefreshToken: null, + oidcTokenType: null, }); sandbox .stub(repository, 'updateOneById') @@ -227,6 +247,11 @@ describe('domains/adminuser', () => { googleOAuth2IdToken: null, googleOAuth2RefreshToken: null, googleOAuth2TokenType: null, + oidcAccessToken: null, + oidcExpiryDate: null, + oidcIdToken: null, + oidcRefreshToken: null, + oidcTokenType: null, }); sandbox.stub(repository, 'removeOneById').withArgs(id).resolves(); sandbox @@ -268,6 +293,11 @@ describe('domains/adminuser', () => { googleOAuth2IdToken: null, googleOAuth2RefreshToken: null, googleOAuth2TokenType: null, + oidcAccessToken: null, + oidcExpiryDate: null, + oidcIdToken: null, + oidcRefreshToken: null, + oidcTokenType: null, }); sandbox.stub(domainsAdminrole, 'listRoles').withArgs(id).resolves([]); @@ -308,6 +338,11 @@ describe('domains/adminuser', () => { googleOAuth2IdToken: null, googleOAuth2RefreshToken: null, googleOAuth2TokenType: null, + oidcAccessToken: null, + oidcExpiryDate: null, + oidcIdToken: null, + oidcRefreshToken: null, + oidcTokenType: null, }); sandbox.stub(domainsAdminrole, 'listRoles').withArgs(id).resolves([]); diff --git a/packages/nodejs/src/constants.ts b/packages/nodejs/src/constants.ts index bb178e244..04120e03b 100644 --- a/packages/nodejs/src/constants.ts +++ b/packages/nodejs/src/constants.ts @@ -140,10 +140,7 @@ export const GOOGLE_OAUTH2_DEFAULT_SCOPES = [ 'https://www.googleapis.com/auth/userinfo.email', ]; -export const OIDC_DEFAULT_SCOPES = [ - 'openid', - 'email', -]; +export const OIDC_DEFAULT_SCOPES = ['openid', 'email']; export const THEME = { RED: 'red', diff --git a/packages/nodejs/src/domains/auth/oidc.ts b/packages/nodejs/src/domains/auth/oidc.ts index 4efe2e712..09529619e 100644 --- a/packages/nodejs/src/domains/auth/oidc.ts +++ b/packages/nodejs/src/domains/auth/oidc.ts @@ -1,4 +1,10 @@ -import { Issuer, generators, Client, TokenSet, CallbackParamsType } from 'openid-client'; +import { + Issuer, + generators, + Client, + TokenSet, + CallbackParamsType, +} from 'openid-client'; import { getDebug } from '../../logging'; import { signJwt } from './jwt'; const debug = getDebug('domains:auth:oidc'); @@ -36,7 +42,9 @@ export const genOidcClient = async ( // issuer.metadata.scopes_supportedでサポートされていないスコープがないかチェック const scopesSupported = issuer.metadata.scopes_supported as string[]; if (scopesSupported) { - const scopes = config.additionalScopes ? OIDC_DEFAULT_SCOPES.concat(config.additionalScopes) : OIDC_DEFAULT_SCOPES; + const scopes = config.additionalScopes + ? OIDC_DEFAULT_SCOPES.concat(config.additionalScopes) + : OIDC_DEFAULT_SCOPES; for (const scope of scopes) { if (!scopesSupported.includes(scope)) { throw new Error(`Unsupported scope: ${scope}`); @@ -71,7 +79,6 @@ export const getOidcAuthorizationUrl = async ( codeVerifier: string, state: string ): Promise => { - // PKCE用のコードベリファイアを生成 const codeChallenge = generators.codeChallenge(codeVerifier); @@ -99,7 +106,6 @@ export const signinOidc = async ( params: CallbackParamsType, oidcConfig: OidcConfig ): Promise => { - debug('params:', params); debug('codeVerifier:', codeVerifier); debug('oidcConfig.callbackUrl:', oidcConfig.callbackUrl); @@ -128,7 +134,7 @@ export const signinOidc = async ( throw invalidGoogleOAuth2Token(); } // emailドメインチェック - const emailDomain = claims.email!.split('@').pop() as string; + const emailDomain = email.split('@').pop() as string; if ( oidcConfig.userHostedDomains?.length && !oidcConfig.userHostedDomains.includes(emailDomain) @@ -176,33 +182,44 @@ export const signinOidc = async ( export const verifyOidcAccessToken = async ( client: Client, userId: string, - credentials: OidcCredentials, + credentials: OidcCredentials ): Promise => { - // リフレッシュトークンがない場合はscope offline_accessがサポートされてないのでintrospection_endpoint or userinfo_endpointでアクセストークンの有効性を検証する if (!credentials.oidcRefreshToken) { + const accessToken: string = credentials.oidcAccessToken ?? ''; debug('Access token verification without refreshtoken userId: %s', userId); debug('client.issuer.metadata. %o', client.issuer.metadata); // introspection_endpointがある場合はアクセストークンを検証 if (client.issuer.metadata.introspection_endpoint) { - debug('Accesstoken validation if introspection_endpoint is supported userId: %s', userId); + debug( + 'Accesstoken validation if introspection_endpoint is supported userId: %s', + userId + ); // アクセストークンを検証 - const introspect = await client.introspect(credentials.oidcAccessToken!, 'access_token').catch((e: Error) => { - debug('introspect failure. userId: %s, err: %o', userId, e); - return e; - }); + const introspect = await client + .introspect(accessToken, 'access_token') + .catch((e: Error) => { + debug('introspect failure. userId: %s, err: %o', userId, e); + return e; + }); // アクセストークン検証でエラーが発生した場合 if (introspect instanceof Error) { - debug('verifyOidcAccessToken introspect invalid access token error. %s', credentials.oidcAccessToken); + debug( + 'verifyOidcAccessToken introspect invalid access token error. %s', + accessToken + ); return false; } // アクセストークンが無効な場合 if (!introspect.active) { - debug('verifyOidcAccessToken introspect invalid access token deactive. %s', credentials.oidcAccessToken); + debug( + 'verifyOidcAccessToken introspect invalid access token deactive. %s', + credentials.oidcAccessToken + ); return false; } @@ -213,23 +230,33 @@ export const verifyOidcAccessToken = async ( // userinfo_endpointがある場合はアクセストークンを検証 if (client.issuer.metadata.userinfo_endpoint) { - debug('Accesstoken validation if userinfo_endpoint is supported userId: %s', userId); + debug( + 'Accesstoken validation if userinfo_endpoint is supported userId: %s', + userId + ); // ユーザー情報を取得できた場合は有効なアクセストークン - const userInfo = await client.userinfo(credentials.oidcAccessToken!).catch((e: Error) => { + const userInfo = await client.userinfo(accessToken).catch((e: Error) => { debug('userinfo failure. userId: %s, err: %o', userId, e); return e; }); // ユーザー情報取得でエラーが発生した場合 if (userInfo instanceof Error) { - debug('verifyOidcAccessToken userinfo invalid access token error. %s, %o', credentials.oidcAccessToken, userInfo); + debug( + 'verifyOidcAccessToken userinfo invalid access token error. %s, %o', + accessToken, + userInfo + ); return false; } // ユーザー情報が取得できなかった場合 if (!userInfo) { - debug('verifyOidcAccessToken userinfo invalid access token. %s', credentials.oidcAccessToken); + debug( + 'verifyOidcAccessToken userinfo invalid access token. %s', + accessToken + ); return false; } @@ -239,19 +266,20 @@ export const verifyOidcAccessToken = async ( } // introspection_endpointとuserinfo_endpointがない場合はアクセストークンを検証できないのでエラー - debug('introspection_endpoint and userinfo_endpoint are not supported') + debug('introspection_endpoint and userinfo_endpoint are not supported'); return false; } // リフレッシュトークンがある場合はリフレッシュトークンを使ってトークンを更新 - const tokenset = await client.refresh(credentials.oidcRefreshToken!); + const refreshToken = credentials.oidcRefreshToken ?? ''; + const tokenset = await client.refresh(refreshToken); if (!tokenset) { - debug('verifyOidcAccessToken invalid refresh token. %s', credentials.oidcRefreshToken); + debug('verifyOidcAccessToken invalid refresh token. %s', refreshToken); return false; } debug('AccessToken refresh success! userId: %s', userId); const newCredentials = formatCredentials(tokenset); - newCredentials.oidcRefreshToken = credentials.oidcRefreshToken; + newCredentials.oidcRefreshToken = refreshToken; await updateOneById(userId, newCredentials); return true; }; diff --git a/packages/nodejs/src/helpers/cookies.ts b/packages/nodejs/src/helpers/cookies.ts index 5b114ce78..a21ca2118 100644 --- a/packages/nodejs/src/helpers/cookies.ts +++ b/packages/nodejs/src/helpers/cookies.ts @@ -79,4 +79,3 @@ export const genOidcCodeVerifierCookie = ( } return genCookie(COOKIE_KEY.OIDC_CODE_VERIFIER, codeVerifier, opts); }; - From 4c5f5644dfa65895af9228fd5c3f8d2b6bcb0a4c Mon Sep 17 00:00:00 2001 From: takoring Date: Mon, 20 Jan 2025 15:03:10 +0900 Subject: [PATCH 08/25] fix(nodejs): reverting pre-commit and pre-push --- .husky/pre-commit | 2 +- .husky/pre-push | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.husky/pre-commit b/.husky/pre-commit index 301335cd6..9603130a3 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,4 +1,4 @@ #!/bin/sh . "$(dirname $0)/_/husky.sh" -#npm run pre-commit +npm run pre-commit diff --git a/.husky/pre-push b/.husky/pre-push index 18bff30fd..695871c4c 100755 --- a/.husky/pre-push +++ b/.husky/pre-push @@ -1,4 +1,4 @@ #!/bin/sh . "$(dirname $0)/_/husky.sh" -#npm run pre-push +npm run pre-push From 60acd18cef7abc25c6746e3470714459453c247c Mon Sep 17 00:00:00 2001 From: takoring Date: Mon, 20 Jan 2025 17:27:22 +0900 Subject: [PATCH 09/25] fix(nodejs): delete callbackUrl from config --- example/nodejs/src/config/development.ts | 1 - example/nodejs/src/config/local.ts | 1 - example/nodejs/src/config/production.ts | 1 - example/nodejs/src/controllers/auth.ts | 9 +++++---- example/nodejs/src/security_handlers/jwt.ts | 5 +---- packages/nodejs/src/domains/auth/oidc.ts | 13 ++++++------- 6 files changed, 12 insertions(+), 18 deletions(-) diff --git a/example/nodejs/src/config/development.ts b/example/nodejs/src/config/development.ts index cfddb1668..5d5d7a96a 100644 --- a/example/nodejs/src/config/development.ts +++ b/example/nodejs/src/config/development.ts @@ -50,7 +50,6 @@ export const get = (): Config => { oidc: { clientId: process.env.OIDC_CLIENT_ID ?? '', clientSecret: process.env.OIDC_CLIENT_SECRET ?? '', - callbackUrl: 'https://localhost:8000/oidcredirect', configurationUrl: 'https://{oidc idp host}/.well-known/openid-configuration', additionalScopes: [], diff --git a/example/nodejs/src/config/local.ts b/example/nodejs/src/config/local.ts index 19980019b..b1b82d67f 100644 --- a/example/nodejs/src/config/local.ts +++ b/example/nodejs/src/config/local.ts @@ -62,7 +62,6 @@ export const get = (mode: Mode): Config => { oidc: { clientId: process.env.OIDC_CLIENT_ID ?? '', clientSecret: process.env.OIDC_CLIENT_SECRET ?? '', - callbackUrl: 'https://localhost:8000/oidcredirect', configurationUrl: 'https://{oidc idp host}/.well-known/openid-configuration', additionalScopes: [], diff --git a/example/nodejs/src/config/production.ts b/example/nodejs/src/config/production.ts index 2dda40ac8..92ab9f83a 100644 --- a/example/nodejs/src/config/production.ts +++ b/example/nodejs/src/config/production.ts @@ -47,7 +47,6 @@ export const get = (): Config => { oidc: { clientId: process.env.OIDC_CLIENT_ID ?? '', clientSecret: process.env.OIDC_CLIENT_SECRET ?? '', - callbackUrl: 'https://localhost:8000/oidcredirect', configurationUrl: 'https://{oidc idp host}/.well-known/openid-configuration', additionalScopes: [], diff --git a/example/nodejs/src/controllers/auth.ts b/example/nodejs/src/controllers/auth.ts index fa42424a7..56f1ef1ef 100644 --- a/example/nodejs/src/controllers/auth.ts +++ b/example/nodejs/src/controllers/auth.ts @@ -37,8 +37,8 @@ export const oidcAuthorization = async ( const { redirectUri } = context.params.query; const state = domainsAuth.genState(); const client = await domainsAuth.genOidcClient( - redirectUri, - ctx.config.auth.oidc + ctx.config.auth.oidc, + redirectUri ); // PKCE用のCodeVerifierを生成 @@ -74,13 +74,14 @@ export const oidcCallback = async (context: RouteContext): Promise => { // OIDC Clientを取得 const client = await domainsAuth.genOidcClient( - redirectUri, - ctx.config.auth.oidc + ctx.config.auth.oidc, + redirectUri ); const params = client.callbackParams(context.req); const token = await domainsAuth.signinOidc( client, codeVerifier as string, + redirectUri, params, ctx.config.auth.oidc ); diff --git a/example/nodejs/src/security_handlers/jwt.ts b/example/nodejs/src/security_handlers/jwt.ts index 9f4711f59..8d5e383cf 100644 --- a/example/nodejs/src/security_handlers/jwt.ts +++ b/example/nodejs/src/security_handlers/jwt.ts @@ -91,10 +91,7 @@ export const jwt = async ( } case AUTH_TYPE.OIDC: { // OIDC認証の場合はアクセストークンの検証 - const client = await domainsAuth.genOidcClient( - ctx.config.auth.oidc.callbackUrl, - ctx.config.auth.oidc - ); + const client = await domainsAuth.genOidcClient(ctx.config.auth.oidc); if (await domainsAuth.verifyOidcAccessToken(client, userId, user)) { return authSuccess(user); } diff --git a/packages/nodejs/src/domains/auth/oidc.ts b/packages/nodejs/src/domains/auth/oidc.ts index 09529619e..82148112a 100644 --- a/packages/nodejs/src/domains/auth/oidc.ts +++ b/packages/nodejs/src/domains/auth/oidc.ts @@ -21,7 +21,6 @@ import { ADMIN_ROLE, AUTH_TYPE, OIDC_DEFAULT_SCOPES } from '../../constants'; export interface OidcClientConfig { clientId: string; clientSecret: string; - callbackUrl: string; configurationUrl: string; } @@ -32,8 +31,8 @@ export interface OidcConfig extends OidcClientConfig { // OIDCクライアントの生成 export const genOidcClient = async ( - redirectUrl: string, - config: OidcConfig + config: OidcConfig, + redirectUri?: string ): Promise => { // OIDCプロバイダーのIssuerを取得 const issuer = await Issuer.discover(config.configurationUrl); @@ -56,14 +55,14 @@ export const genOidcClient = async ( throw new Error('client.issuer.metadata.scopes_supported is not found'); } - debug('redirectUrl %s', redirectUrl); + debug('redirectUri %s', redirectUri); // クライアントの作成 return new issuer.Client({ client_id: config.clientId, client_secret: config.clientSecret, - redirect_uris: [redirectUrl], response_types: ['code'], + ...(redirectUri && { redirect_uris: [redirectUri] }), }); }; @@ -103,14 +102,14 @@ export const getOidcAuthorizationUrl = async ( export const signinOidc = async ( client: Client, codeVerifier: string, + redirectUri: string, params: CallbackParamsType, oidcConfig: OidcConfig ): Promise => { debug('params:', params); debug('codeVerifier:', codeVerifier); - debug('oidcConfig.callbackUrl:', oidcConfig.callbackUrl); - const tokenSet = await client.callback(oidcConfig.callbackUrl, params, { + const tokenSet = await client.callback(redirectUri, params, { code_verifier: codeVerifier, state: params.state, }); From c7cdab62804d0a86592e9b4c314f0a6933c1ce88 Mon Sep 17 00:00:00 2001 From: takoring Date: Mon, 20 Jan 2025 17:40:30 +0900 Subject: [PATCH 10/25] fix(nodejs): error Object Definition --- packages/nodejs/src/domains/adminuser.ts | 6 +++--- packages/nodejs/src/domains/auth/oidc.ts | 11 ++++++----- packages/nodejs/src/errors.ts | 8 ++++++++ 3 files changed, 17 insertions(+), 8 deletions(-) diff --git a/packages/nodejs/src/domains/adminuser.ts b/packages/nodejs/src/domains/adminuser.ts index ac374a83d..ddcaf21da 100644 --- a/packages/nodejs/src/domains/adminuser.ts +++ b/packages/nodejs/src/domains/adminuser.ts @@ -6,7 +6,7 @@ import { } from '../constants'; import { ListWithPager, genPasswordHash } from '../helpers'; import { FindConditions, repositoryContainer } from '../repositories'; -import { adminUserNotFound } from '../errors'; +import { adminUserNotFound, invalidAuthType } from '../errors'; import { listRoles, listUsers, @@ -228,7 +228,7 @@ export const createOne = async ( ...adminUserOidc, } as AdminUserOidcCreateAttributes; } else { - throw new Error('Invalid authType'); + throw invalidAuthType(); } const user = await repository.createOne(obj); @@ -265,7 +265,7 @@ export const updateOneById = async ( const adminUserOidc = adminUser as AdminUserOidcUpdatePayload; await repository.updateOneById(id, adminUserOidc); } else { - throw new Error('Invalid authType'); + throw invalidAuthType(); } if (roleIds?.length) { diff --git a/packages/nodejs/src/domains/auth/oidc.ts b/packages/nodejs/src/domains/auth/oidc.ts index 82148112a..332de9263 100644 --- a/packages/nodejs/src/domains/auth/oidc.ts +++ b/packages/nodejs/src/domains/auth/oidc.ts @@ -10,8 +10,9 @@ import { signJwt } from './jwt'; const debug = getDebug('domains:auth:oidc'); import { forbidden, - invalidGoogleOAuth2Token, + invalidOidcToken, signinFailed, + unsupportedScope, } from '../../errors'; import { createOne, findOneByEmail, updateOneById } from '../adminuser'; import { addRoleForUser } from '../adminrole'; @@ -46,13 +47,13 @@ export const genOidcClient = async ( : OIDC_DEFAULT_SCOPES; for (const scope of scopes) { if (!scopesSupported.includes(scope)) { - throw new Error(`Unsupported scope: ${scope}`); + throw unsupportedScope(); } } } else { // scopes_supportedが見つからない場合はエラー debug('client.issuer.metadata.scopes_supported is not found'); - throw new Error('client.issuer.metadata.scopes_supported is not found'); + throw unsupportedScope(); } debug('redirectUri %s', redirectUri); @@ -119,7 +120,7 @@ export const signinOidc = async ( if (!credentials.oidcIdToken) { debug('signinOidc invalid authentication codeVerifier. %s', codeVerifier); - throw invalidGoogleOAuth2Token(); + throw invalidOidcToken(); } // emailチェック @@ -130,7 +131,7 @@ export const signinOidc = async ( claims, tokenSet.id_token ); - throw invalidGoogleOAuth2Token(); + throw invalidOidcToken(); } // emailドメインチェック const emailDomain = email.split('@').pop() as string; diff --git a/packages/nodejs/src/errors.ts b/packages/nodejs/src/errors.ts index a0f0068a5..0c58e0f00 100644 --- a/packages/nodejs/src/errors.ts +++ b/packages/nodejs/src/errors.ts @@ -74,3 +74,11 @@ export const unableToDeleteRole = (): VironError => { export const operationNotFound = (): VironError => { return new VironError('Operation Not Found.', 404); }; + +export const invalidAuthType = (): VironError => { + return new VironError('Invalid Auth type.', 400); +}; + +export const unsupportedScope = (): VironError => { + return new VironError('Unsupported scope.', 500); +}; From b7ceb539ca53bdd3808ce907bb733cfeacaa8581 Mon Sep 17 00:00:00 2001 From: takoring Date: Mon, 20 Jan 2025 18:12:39 +0900 Subject: [PATCH 11/25] fix(nodejs): refactoring config --- packages/nodejs/src/domains/auth/oidc.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/nodejs/src/domains/auth/oidc.ts b/packages/nodejs/src/domains/auth/oidc.ts index 332de9263..0fb89d700 100644 --- a/packages/nodejs/src/domains/auth/oidc.ts +++ b/packages/nodejs/src/domains/auth/oidc.ts @@ -74,7 +74,7 @@ export const genOidcCodeVerifier = async (): Promise => { // Oidc認可画面URLを取得 export const getOidcAuthorizationUrl = async ( - oidcConfig: OidcConfig, + config: OidcConfig, client: Client, codeVerifier: string, state: string @@ -86,8 +86,8 @@ export const getOidcAuthorizationUrl = async ( // 認証URLを生成 const authorizationUrl = client.authorizationUrl({ - scope: oidcConfig.additionalScopes - ? OIDC_DEFAULT_SCOPES.concat(oidcConfig.additionalScopes).join(' ') + scope: config.additionalScopes + ? OIDC_DEFAULT_SCOPES.concat(config.additionalScopes).join(' ') : OIDC_DEFAULT_SCOPES.join(' '), code_challenge: codeChallenge, code_challenge_method: 'S256', @@ -105,7 +105,7 @@ export const signinOidc = async ( codeVerifier: string, redirectUri: string, params: CallbackParamsType, - oidcConfig: OidcConfig + config: OidcConfig ): Promise => { debug('params:', params); debug('codeVerifier:', codeVerifier); @@ -136,8 +136,8 @@ export const signinOidc = async ( // emailドメインチェック const emailDomain = email.split('@').pop() as string; if ( - oidcConfig.userHostedDomains?.length && - !oidcConfig.userHostedDomains.includes(emailDomain) + config.userHostedDomains?.length && + !config.userHostedDomains.includes(emailDomain) ) { // 許可されていないメールドメイン debug('signinOidc illegal user email: %s', email); From f4557c423cf6768d7df995cc856cdc031931d459 Mon Sep 17 00:00:00 2001 From: takoring Date: Tue, 21 Jan 2025 14:39:53 +0900 Subject: [PATCH 12/25] fix(nodejs): fixes made in response to reviews --- example/nodejs/package.json | 2 +- example/nodejs/src/controllers/auth.ts | 2 + package-lock.json | 130 ++++++++++++----------- packages/nodejs/package.json | 1 + packages/nodejs/src/constants.ts | 3 +- packages/nodejs/src/domains/adminuser.ts | 86 ++++++++------- packages/nodejs/src/domains/auth/oidc.ts | 36 ++++--- packages/nodejs/src/helpers/cookies.ts | 3 +- 8 files changed, 146 insertions(+), 117 deletions(-) diff --git a/example/nodejs/package.json b/example/nodejs/package.json index bceeecc97..596c32f20 100644 --- a/example/nodejs/package.json +++ b/example/nodejs/package.json @@ -21,7 +21,7 @@ "multer": "^1.4.3", "multer-s3": "^3.0.1", "mysql2": "^2.2.5", - "openid-client": "^6.1.7", + "openid-client": "^4.7.4", "pino": "^7.6.4", "pino-http": "^6.6.0", "sequelize": "^6.5.0", diff --git a/example/nodejs/src/controllers/auth.ts b/example/nodejs/src/controllers/auth.ts index 56f1ef1ef..87b497a95 100644 --- a/example/nodejs/src/controllers/auth.ts +++ b/example/nodejs/src/controllers/auth.ts @@ -91,6 +91,8 @@ export const oidcCallback = async (context: RouteContext): Promise => { maxAge: ctx.config.auth.jwt.expirationSec, }) ); + context.origRes.clearCookie(COOKIE_KEY.OIDC_STATE); + context.origRes.clearCookie(COOKIE_KEY.OIDC_CODE_VERIFIER); context.res.status(204).end(); }; diff --git a/package-lock.json b/package-lock.json index 6984eb001..e1dbea2f1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -37,7 +37,6 @@ "cookie-parser": "^1.4.5", "cors": "^2.8.5", "cross-env": "^7.0.3", - "dotenv-cli": "^8.0.0", "exegesis-express": "^2.0.1", "express": "^4.19.2", "i18next": "^21.6.7", @@ -48,7 +47,7 @@ "multer": "^1.4.3", "multer-s3": "^3.0.1", "mysql2": "^2.2.5", - "openid-client": "^6.1.7", + "openid-client": "^4.7.4", "pino": "^7.6.4", "pino-http": "^6.6.0", "sequelize": "^6.5.0", @@ -1167,6 +1166,20 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, + "example/nodejs/node_modules/jose": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/jose/-/jose-2.0.7.tgz", + "integrity": "sha512-5hFWIigKqC+e/lRyQhfnirrAqUdIPMB7SJRqflJaO29dW7q5DFvH1XCSTmv6PQ6pb++0k6MJlLRoS0Wv4s38Wg==", + "dependencies": { + "@panva/asn1.js": "^1.0.0" + }, + "engines": { + "node": ">=10.13.0 < 13 || >=13.7.0" + }, + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "example/nodejs/node_modules/jsonwebtoken": { "version": "8.5.1", "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-8.5.1.tgz", @@ -1223,6 +1236,17 @@ "node": ">=12.0.0" } }, + "example/nodejs/node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, "example/nodejs/node_modules/mongodb": { "version": "5.9.1", "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-5.9.1.tgz", @@ -1287,6 +1311,34 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" }, + "example/nodejs/node_modules/object-hash": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-2.2.0.tgz", + "integrity": "sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==", + "engines": { + "node": ">= 6" + } + }, + "example/nodejs/node_modules/openid-client": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-4.9.1.tgz", + "integrity": "sha512-DYUF07AHjI3QDKqKbn2F7RqozT4hyi4JvmpodLrq0HHoNP7t/AjeG/uqiBK1/N2PZSAQEThVjDLHSmJN4iqu/w==", + "dependencies": { + "aggregate-error": "^3.1.0", + "got": "^11.8.0", + "jose": "^2.0.5", + "lru-cache": "^6.0.0", + "make-error": "^1.3.6", + "object-hash": "^2.0.1", + "oidc-token-hash": "^5.0.1" + }, + "engines": { + "node": "^10.19.0 || >=12.0.0 < 13 || >=13.7.0 < 14 || >= 14.2.0" + }, + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "example/nodejs/node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", @@ -1468,6 +1520,11 @@ "node": "^12.13.0 || ^14.15.0 || >=16.0.0" } }, + "example/nodejs/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" + }, "example/nodejs/node_modules/yargs-parser": { "version": "21.1.1", "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", @@ -29093,39 +29150,6 @@ "node": ">=10" } }, - "node_modules/dotenv-cli": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/dotenv-cli/-/dotenv-cli-8.0.0.tgz", - "integrity": "sha512-aLqYbK7xKOiTMIRf1lDPbI+Y+Ip/wo5k3eyp6ePysVaSqbyxjyK3dK35BTxG+rmd7djf5q2UPs4noPNH+cj0Qw==", - "dependencies": { - "cross-spawn": "^7.0.6", - "dotenv": "^16.3.0", - "dotenv-expand": "^10.0.0", - "minimist": "^1.2.6" - }, - "bin": { - "dotenv": "cli.js" - } - }, - "node_modules/dotenv-cli/node_modules/dotenv": { - "version": "16.4.7", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.7.tgz", - "integrity": "sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ==", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://dotenvx.com" - } - }, - "node_modules/dotenv-cli/node_modules/dotenv-expand": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-10.0.0.tgz", - "integrity": "sha512-GopVGCpVS1UKH75VKHGuQFqS1Gusej0z4FyQkPdwjil2gNIv+LNsqBlboOzpJFZKVT95GkCyWJbBSdFEFUWI2A==", - "engines": { - "node": ">=12" - } - }, "node_modules/dotenv-expand": { "version": "5.1.0", "license": "BSD-2-Clause" @@ -39746,14 +39770,6 @@ "@sideway/pinpoint": "^2.0.0" } }, - "node_modules/jose": { - "version": "5.9.6", - "resolved": "https://registry.npmjs.org/jose/-/jose-5.9.6.tgz", - "integrity": "sha512-AMlnetc9+CV9asI19zHmrgS/WYsWUwCn2R7RzlbJWD7F9eWYUTGyBmU9o6PxngtLGOiDGPRu+Uc4fhKzbpteZQ==", - "funding": { - "url": "https://github.com/sponsors/panva" - } - }, "node_modules/joycon": { "version": "3.1.1", "dev": true, @@ -45678,14 +45694,6 @@ "version": "2.2.7", "license": "MIT" }, - "node_modules/oauth4webapi": { - "version": "3.1.4", - "resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.1.4.tgz", - "integrity": "sha512-eVfN3nZNbok2s/ROifO0UAc5G8nRoLSbrcKJ09OqmucgnhXEfdIQOR4gq1eJH1rN3gV7rNw62bDEgftsgFtBEg==", - "funding": { - "url": "https://github.com/sponsors/panva" - } - }, "node_modules/object-assign": { "version": "4.1.1", "license": "MIT", @@ -46009,18 +46017,6 @@ "opener": "bin/opener-bin.js" } }, - "node_modules/openid-client": { - "version": "6.1.7", - "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-6.1.7.tgz", - "integrity": "sha512-JfY/KvQgOutmG2P+oVNKInE7zIh+im1MQOaO7g5CtNnTWMociA563WweiEMKfR9ry9XG3K2HGvj9wEqhCQkPMg==", - "dependencies": { - "jose": "^5.9.6", - "oauth4webapi": "^3.1.4" - }, - "funding": { - "url": "https://github.com/sponsors/panva" - } - }, "node_modules/opentracing": { "version": "0.14.7", "resolved": "https://registry.npmjs.org/opentracing/-/opentracing-0.14.7.tgz", @@ -58901,6 +58897,7 @@ "lint-staged": "^12.3.1", "mongodb-memory-server": "^8.2.0", "openapi3-ts": "2.0.1", + "openid-client": "^4.7.4", "prettier": "^2.2.1", "sinon": "^12.0.1", "ts-jest": "^29.1.1", @@ -60004,6 +60001,7 @@ "version": "2.0.7", "resolved": "https://registry.npmjs.org/jose/-/jose-2.0.7.tgz", "integrity": "sha512-5hFWIigKqC+e/lRyQhfnirrAqUdIPMB7SJRqflJaO29dW7q5DFvH1XCSTmv6PQ6pb++0k6MJlLRoS0Wv4s38Wg==", + "dev": true, "dependencies": { "@panva/asn1.js": "^1.0.0" }, @@ -60037,6 +60035,7 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dev": true, "dependencies": { "yallist": "^4.0.0" }, @@ -60125,6 +60124,7 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-2.2.0.tgz", "integrity": "sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==", + "dev": true, "engines": { "node": ">= 6" } @@ -60133,6 +60133,7 @@ "version": "4.7.4", "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-4.7.4.tgz", "integrity": "sha512-n+RURXYuR0bBZo9i0pn+CXZSyg5JYQ1nbwEwPQvLE7EcJt/vMZ2iIMjLehl5DvCN53XUoPVZs9KAE5r6d9fxsw==", + "dev": true, "dependencies": { "aggregate-error": "^3.1.0", "got": "^11.8.0", @@ -60333,7 +60334,8 @@ "packages/nodejs/node_modules/yallist": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true }, "packages/nodejs/node_modules/yargs-parser": { "version": "21.1.1", diff --git a/packages/nodejs/package.json b/packages/nodejs/package.json index 4adc252e8..ff6871b79 100644 --- a/packages/nodejs/package.json +++ b/packages/nodejs/package.json @@ -60,6 +60,7 @@ "lint-staged": "^12.3.1", "mongodb-memory-server": "^8.2.0", "openapi3-ts": "2.0.1", + "openid-client": "^4.7.4", "prettier": "^2.2.1", "sinon": "^12.0.1", "ts-jest": "^29.1.1", diff --git a/packages/nodejs/src/constants.ts b/packages/nodejs/src/constants.ts index 04120e03b..22093c087 100644 --- a/packages/nodejs/src/constants.ts +++ b/packages/nodejs/src/constants.ts @@ -126,7 +126,8 @@ export const DEFAULT_JWT_EXPIRATION_SEC = 24 * 60 * 60; export const DEBUG_LOG_PREFIX = '@viron/lib:'; export const CASBIN_SYNC_INTERVAL_MSEC = 1 * 60 * 1000; export const OAUTH2_STATE_EXPIRATION_SEC = 10 * 60; -export const OIDC_STATE_EXPIRATION_SEC = 10 * 60; +export const OIDC_STATE_EXPIRATION_SEC = 1 * 60; +export const OIDC_CODE_VERIFIER_EXPIRATION_SEC = 1 * 60; export const REVOKED_TOKEN_RETENTION_SEC = 30 * 24 * 60 * 60; export const COOKIE_KEY = { diff --git a/packages/nodejs/src/domains/adminuser.ts b/packages/nodejs/src/domains/adminuser.ts index ddcaf21da..0f4dd9f84 100644 --- a/packages/nodejs/src/domains/adminuser.ts +++ b/packages/nodejs/src/domains/adminuser.ts @@ -208,27 +208,34 @@ export const createOne = async ( const { roleIds, ...adminUser } = payload; let obj; - if (authType === AUTH_TYPE.EMAIL) { - const adminUserEmail = adminUser as AdminUserEmailCreatePayload; - obj = { - authType: AUTH_TYPE.EMAIL, - ...adminUserEmail, - ...genPasswordHash(adminUserEmail.password), - } as AdminUserEmailCreateAttributes; - } else if (authType === AUTH_TYPE.GOOGLE) { - const adminUserGogle = adminUser as AdminUserGoogleCreatePayload; - obj = { - authType: AUTH_TYPE.GOOGLE, - ...adminUserGogle, - } as AdminUserGoogleCreateAttributes; - } else if (authType === AUTH_TYPE.OIDC) { - const adminUserOidc = adminUser as AdminUserOidcCreatePayload; - obj = { - authType: AUTH_TYPE.OIDC, - ...adminUserOidc, - } as AdminUserOidcCreateAttributes; - } else { - throw invalidAuthType(); + switch (authType) { + case AUTH_TYPE.EMAIL: { + const adminUserEmail = adminUser as AdminUserEmailCreatePayload; + obj = { + authType: AUTH_TYPE.EMAIL, + ...adminUserEmail, + ...genPasswordHash(adminUserEmail.password), + } as AdminUserEmailCreateAttributes; + break; + } + case AUTH_TYPE.GOOGLE: { + const adminUserGoogle = adminUser as AdminUserGoogleCreatePayload; + obj = { + authType: AUTH_TYPE.GOOGLE, + ...adminUserGoogle, + } as AdminUserGoogleCreateAttributes; + break; + } + case AUTH_TYPE.OIDC: { + const adminUserOidc = adminUser as AdminUserOidcCreatePayload; + obj = { + authType: AUTH_TYPE.OIDC, + ...adminUserOidc, + } as AdminUserOidcCreateAttributes; + break; + } + default: + throw invalidAuthType(); } const user = await repository.createOne(obj); @@ -250,22 +257,29 @@ export const updateOneById = async ( } const { roleIds, ...adminUser } = payload; - if (user.authType === AUTH_TYPE.EMAIL) { - const adminUserEmail = adminUser as AdminUserEmailUpdatePayload; - if (adminUserEmail.password) { - await repository.updateOneById( - id, - genPasswordHash(adminUserEmail.password) - ); + switch (user.authType) { + case AUTH_TYPE.EMAIL: { + const adminUserEmail = adminUser as AdminUserEmailUpdatePayload; + if (adminUserEmail.password) { + await repository.updateOneById( + id, + genPasswordHash(adminUserEmail.password) + ); + } + break; + } + case AUTH_TYPE.GOOGLE: { + const adminUserGoogle = adminUser as AdminUserGoogleUpdatePayload; + await repository.updateOneById(id, adminUserGoogle); + break; + } + case AUTH_TYPE.OIDC: { + const adminUserOidc = adminUser as AdminUserOidcUpdatePayload; + await repository.updateOneById(id, adminUserOidc); + break; } - } else if (user.authType === AUTH_TYPE.GOOGLE) { - const adminUserGoogle = adminUser as AdminUserGoogleUpdatePayload; - await repository.updateOneById(id, adminUserGoogle); - } else if (user.authType === AUTH_TYPE.OIDC) { - const adminUserOidc = adminUser as AdminUserOidcUpdatePayload; - await repository.updateOneById(id, adminUserOidc); - } else { - throw invalidAuthType(); + default: + throw invalidAuthType(); } if (roleIds?.length) { diff --git a/packages/nodejs/src/domains/auth/oidc.ts b/packages/nodejs/src/domains/auth/oidc.ts index 0fb89d700..1595d5877 100644 --- a/packages/nodejs/src/domains/auth/oidc.ts +++ b/packages/nodejs/src/domains/auth/oidc.ts @@ -40,20 +40,22 @@ export const genOidcClient = async ( debug('Discovered issuer %o', issuer); // issuer.metadata.scopes_supportedでサポートされていないスコープがないかチェック - const scopesSupported = issuer.metadata.scopes_supported as string[]; - if (scopesSupported) { + // https://openid.net/specs/openid-connect-discovery-1_0.html#IssuerDiscovery + // RECOMMENDED. JSON array containing a list of the OAuth 2.0 [RFC6749] scope values that this server supports. The server MUST support the openid scope value. Servers MAY choose not to advertise some supported scope values even when this parameter is used, although those defined in [OpenID.Core] SHOULD be listed, if supported. + // 推奨プロパティなのでない場合もある + const scopesSupported = issuer.metadata.scopes_supported + ? (issuer.metadata.scopes_supported as string[]) + : []; + // scopes_supportedがある場合はチェック + if (scopesSupported.length > 0) { + // 追加スコープがある場合は追加スコープを含めてチェック const scopes = config.additionalScopes ? OIDC_DEFAULT_SCOPES.concat(config.additionalScopes) : OIDC_DEFAULT_SCOPES; - for (const scope of scopes) { - if (!scopesSupported.includes(scope)) { - throw unsupportedScope(); - } + // scopes の中のどれか一つでもサポートされていない場合はエラー + if (scopes.some((scope) => !scopesSupported.includes(scope))) { + throw unsupportedScope(); } - } else { - // scopes_supportedが見つからない場合はエラー - debug('client.issuer.metadata.scopes_supported is not found'); - throw unsupportedScope(); } debug('redirectUri %s', redirectUri); @@ -82,8 +84,6 @@ export const getOidcAuthorizationUrl = async ( // PKCE用のコードベリファイアを生成 const codeChallenge = generators.codeChallenge(codeVerifier); - debug('clinet issuer metadata %o', client.issuer.metadata.scopes_supported); - // 認証URLを生成 const authorizationUrl = client.authorizationUrl({ scope: config.additionalScopes @@ -186,7 +186,15 @@ export const verifyOidcAccessToken = async ( ): Promise => { // リフレッシュトークンがない場合はscope offline_accessがサポートされてないのでintrospection_endpoint or userinfo_endpointでアクセストークンの有効性を検証する if (!credentials.oidcRefreshToken) { - const accessToken: string = credentials.oidcAccessToken ?? ''; + if (!credentials.oidcAccessToken) { + debug( + 'verifyOidcAccessToken invalid access token. %s', + credentials.oidcAccessToken + ); + return false; + } + + const accessToken: string = credentials.oidcAccessToken; debug('Access token verification without refreshtoken userId: %s', userId); debug('client.issuer.metadata. %o', client.issuer.metadata); @@ -271,7 +279,7 @@ export const verifyOidcAccessToken = async ( } // リフレッシュトークンがある場合はリフレッシュトークンを使ってトークンを更新 - const refreshToken = credentials.oidcRefreshToken ?? ''; + const refreshToken = credentials.oidcRefreshToken; const tokenset = await client.refresh(refreshToken); if (!tokenset) { debug('verifyOidcAccessToken invalid refresh token. %s', refreshToken); diff --git a/packages/nodejs/src/helpers/cookies.ts b/packages/nodejs/src/helpers/cookies.ts index a21ca2118..e5183f854 100644 --- a/packages/nodejs/src/helpers/cookies.ts +++ b/packages/nodejs/src/helpers/cookies.ts @@ -4,6 +4,7 @@ import { DEFAULT_JWT_EXPIRATION_SEC, OAUTH2_STATE_EXPIRATION_SEC, OIDC_STATE_EXPIRATION_SEC, + OIDC_CODE_VERIFIER_EXPIRATION_SEC, } from '../constants'; // Cookie文字列を生成 @@ -75,7 +76,7 @@ export const genOidcCodeVerifierCookie = ( ): string => { const opts = Object.assign({}, options); if (!opts.maxAge && !opts.expires) { - opts.maxAge = OIDC_STATE_EXPIRATION_SEC; + opts.maxAge = OIDC_CODE_VERIFIER_EXPIRATION_SEC; } return genCookie(COOKIE_KEY.OIDC_CODE_VERIFIER, codeVerifier, opts); }; From 9c6f2939401a230fe9b64db71e9b661ab12dfab3 Mon Sep 17 00:00:00 2001 From: takoring Date: Tue, 21 Jan 2025 19:09:52 +0900 Subject: [PATCH 13/25] fix(nodejs): refactored the adminuser update process --- packages/nodejs/src/domains/adminuser.ts | 33 +++++++++++++++++++----- 1 file changed, 27 insertions(+), 6 deletions(-) diff --git a/packages/nodejs/src/domains/adminuser.ts b/packages/nodejs/src/domains/adminuser.ts index 0f4dd9f84..6b02f5f9e 100644 --- a/packages/nodejs/src/domains/adminuser.ts +++ b/packages/nodejs/src/domains/adminuser.ts @@ -257,31 +257,52 @@ export const updateOneById = async ( } const { roleIds, ...adminUser } = payload; + let obj; switch (user.authType) { case AUTH_TYPE.EMAIL: { const adminUserEmail = adminUser as AdminUserEmailUpdatePayload; if (adminUserEmail.password) { - await repository.updateOneById( - id, - genPasswordHash(adminUserEmail.password) - ); + obj = { + ...adminUserEmail, + ...genPasswordHash(adminUserEmail.password), + } as AdminUserEmailUpdateAttributes; } break; } case AUTH_TYPE.GOOGLE: { const adminUserGoogle = adminUser as AdminUserGoogleUpdatePayload; - await repository.updateOneById(id, adminUserGoogle); + if (adminUserGoogle.googleOAuth2AccessToken) { + obj = { + googleOAuth2AccessToken: adminUserGoogle.googleOAuth2AccessToken, + googleOAuth2ExpiryDate: adminUserGoogle.googleOAuth2ExpiryDate, + googleOAuth2IdToken: adminUserGoogle.googleOAuth2IdToken, + googleOAuth2RefreshToken: adminUserGoogle.googleOAuth2RefreshToken, + googleOAuth2TokenType: adminUserGoogle.googleOAuth2TokenType, + } as AdminUserGoogleUpdateAttributes; + } break; } case AUTH_TYPE.OIDC: { const adminUserOidc = adminUser as AdminUserOidcUpdatePayload; - await repository.updateOneById(id, adminUserOidc); + if (adminUserOidc.oidcAccessToken) { + obj = { + oidcAccessToken: adminUserOidc.oidcAccessToken, + oidcExpiryDate: adminUserOidc.oidcExpiryDate, + oidcIdToken: adminUserOidc.oidcIdToken, + oidcRefreshToken: adminUserOidc.oidcRefreshToken, + oidcTokenType: adminUserOidc.oidcTokenType, + } as AdminUserOidcUpdateAttributes; + } break; } default: throw invalidAuthType(); } + if (obj) { + await repository.updateOneById(id, obj); + } + if (roleIds?.length) { await updateRolesForUser(id, roleIds); } From befd0e41ec234de4cf53b0a3696dcac40a632587 Mon Sep 17 00:00:00 2001 From: takoring Date: Tue, 21 Jan 2025 19:31:39 +0900 Subject: [PATCH 14/25] fix(nodejs): when using email, only the password is updated --- packages/nodejs/src/domains/adminuser.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/nodejs/src/domains/adminuser.ts b/packages/nodejs/src/domains/adminuser.ts index 6b02f5f9e..13c156c0b 100644 --- a/packages/nodejs/src/domains/adminuser.ts +++ b/packages/nodejs/src/domains/adminuser.ts @@ -263,7 +263,6 @@ export const updateOneById = async ( const adminUserEmail = adminUser as AdminUserEmailUpdatePayload; if (adminUserEmail.password) { obj = { - ...adminUserEmail, ...genPasswordHash(adminUserEmail.password), } as AdminUserEmailUpdateAttributes; } From 3c22aaf73473ad0c4d9f7f445775f4f75572c49b Mon Sep 17 00:00:00 2001 From: takoring Date: Wed, 22 Jan 2025 19:22:21 +0900 Subject: [PATCH 15/25] fix(nodejs): oidc domain test add --- .../__tests__/domains/auth/oidc.test.ts | 844 ++++++++++++++++++ 1 file changed, 844 insertions(+) create mode 100644 packages/nodejs/__tests__/domains/auth/oidc.test.ts diff --git a/packages/nodejs/__tests__/domains/auth/oidc.test.ts b/packages/nodejs/__tests__/domains/auth/oidc.test.ts new file mode 100644 index 000000000..10a4f240a --- /dev/null +++ b/packages/nodejs/__tests__/domains/auth/oidc.test.ts @@ -0,0 +1,844 @@ +import assert from 'assert'; +import sinon from 'sinon'; +import { Issuer, Client, IdTokenClaims } from 'openid-client'; +import { + getOidcAuthorizationUrl, + genOidcClient, + signinOidc, + verifyOidcAccessToken, + initJwt, +} from '../../../src/domains/auth'; +import { + unsupportedScope, + invalidOidcToken, + forbidden, + signinFailed, +} from '../../../src/errors'; +import { + AdminUser, + AdminUserCreateAttributes, + AdminUserUpdateAttributes, + findOneByEmail, + createOne, +} from '../../../src/domains/adminuser'; +import { addRoleForUser, listRoles } from '../../../src/domains/adminrole'; +import { AUTH_TYPE, ADMIN_ROLE } from '../../../src/constants'; +import { Repository, repositoryContainer } from '../../../src/repositories'; + +describe('domains/auth/oidc', () => { + const sandbox = sinon.createSandbox(); + + let repository: Repository< + AdminUser, + AdminUserCreateAttributes, + AdminUserUpdateAttributes + >; + + beforeAll(() => { + repository = repositoryContainer.getAdminUserRepository(); + }); + + afterEach(() => { + sandbox.restore(); + }); + + describe('genOidcClient', () => { + it('OIDCクライアントの生成に成功する', async () => { + const redirectUri = 'https://example.com/oidcredirect'; + const config = { + clientId: 'oidc-client-id', + clientSecret: 'oidc-client-secret', + configurationUrl: + 'https://example.com/.well-known/openid-configuration', + additionalScopes: ['email'], + }; + + const mockClient = {} as unknown as Client; + const clientStub = sandbox.stub().returns(mockClient); + + const mockIssuer = { + metadata: { scopes_supported: ['openid', 'email'] }, + Client: clientStub, + } as unknown as Issuer; + + const discoverStub = sandbox + .stub(Issuer, 'discover') + .resolves(mockIssuer); + + await genOidcClient(config, redirectUri); + + sinon.assert.calledOnceWithExactly(discoverStub, config.configurationUrl); + sinon.assert.calledOnceWithExactly(clientStub, { + client_id: config.clientId, + client_secret: config.clientSecret, + redirect_uris: [redirectUri], + response_types: ['code'], + }); + }); + it('サポートされてないscopeがある場合はエラー', async () => { + const redirectUri = 'https://example.com/oidcredirect'; + const config = { + clientId: 'oidc-client-id', + clientSecret: 'oidc-client-secret', + configurationUrl: + 'https://example.com/.well-known/openid-configuration', + additionalScopes: ['email', 'offline_access'], + }; + + const mockClient = {} as unknown as Client; + const clientStub = sandbox.stub().returns(mockClient); + + const mockIssuer = { + metadata: { scopes_supported: ['openid', 'email'] }, // `unsupported-scope` はサポートされていない + Client: clientStub, + } as unknown as Issuer; + + const discoverStub = sandbox + .stub(Issuer, 'discover') + .resolves(mockIssuer); + + await assert.rejects( + genOidcClient(config, redirectUri), + unsupportedScope() + ); + + sinon.assert.calledOnceWithExactly(discoverStub, config.configurationUrl); + sinon.assert.notCalled(clientStub); // クライアント生成は呼び出されない + }); + }); + + describe('getOidcAuthorizationUrl', () => { + it('OIDCのIdpへの認証画面URL生成に成功する', async () => { + const redirectUri = 'https://example.com/oidcredirect'; + const state = 'XXXXXXX'; + const codeVerifier = 'YYYYYYY'; + const config = { + clientId: 'oidc-client-id', + clientSecret: 'oidc-client-secret', + configurationUrl: + 'https://example.com/.well-known/openid-configuration', + additionalScopes: [], + }; + const authorizationUrl = `https://idp.jp/oidc/authz?client_id=${config.clientId}&scope=openid%20email&response_type=code&redirect_uri=https%3A%2F%2Fviron.work%2Foidcredirect&code_challenge=dummy&code_challenge_method=S256&state=${state}`; + + // モックのClientを作成 + const authorizationUrlStub = sandbox.stub().returns(authorizationUrl); + const mockClient = { + authorizationUrl: authorizationUrlStub, + } as unknown as Client; + + // モックのIssuerを作成 + const clientStub = sandbox.stub().returns(mockClient); + const mockIssuer = { + Client: clientStub, + metadata: {}, // 必須プロパティ + keystore: sandbox.stub(), // 必須プロパティ + } as unknown as Issuer; + + // Issuer.discover をモック + const discoverStub = sandbox + .stub(Issuer, 'discover') + .resolves(mockIssuer); + + // テスト対象の関数を呼び出し + const client = await genOidcClient(config, redirectUri); + const result = await getOidcAuthorizationUrl( + config, + client, + codeVerifier, + state + ); + + // モックが期待通りに呼び出されたか確認 + sinon.assert.calledOnceWithExactly( + discoverStub, + 'https://example.com/.well-known/openid-configuration' + ); + sinon.assert.calledOnceWithExactly(clientStub, { + client_id: 'oidc-client-id', + client_secret: 'oidc-client-secret', + redirect_uris: [redirectUri], + response_types: ['code'], + }); + sinon.assert.calledOnceWithExactly(authorizationUrlStub, { + scope: 'openid email', + state, + code_challenge: sinon.match.string, + code_challenge_method: 'S256', + }); + + // 結果の検証 + assert.strictEqual(result, authorizationUrl); + const url = new URL(result); + assert.strictEqual(url.searchParams.get('state'), state); + assert.strictEqual(url.searchParams.get('client_id'), config.clientId); + }); + }); + + describe('signinOidc', () => { + beforeAll(() => { + initJwt({ + secret: 'test', + provider: 'oidc', + }); + }); + it('OIDCのサインインに成功する', async () => { + const codeVerifier = 'valid-code-verifier'; + const redirectUri = 'https://example.com/oidc/callback'; + const params = { state: 'state', code: 'auth-code' }; + const config = { + clientId: 'oidc-client-id', + clientSecret: 'oidc-client-secret', + configurationUrl: + 'https://example.com/.well-known/openid-configuration', + additionalScopes: [], + userHostedDomains: ['example.com'], + }; + + const mockTokenSet = { + access_token: 'xxxxx', + id_token: 'yyyyy', + claims: (): IdTokenClaims => ({ + email: 'user@example.com', + sub: 'sub', + aud: 'aud', + exp: 1737455830, + iat: 1737455830, + iss: 'iss', + }), + expired: (): boolean => false, + }; + + const callbackStub = sandbox.stub().returns(mockTokenSet); + + // クライアントのモック + const mockClient = { + callback: callbackStub, + } as unknown as Client; + + // signinOidc関数を実行して結果を取得 + const result = await signinOidc( + mockClient, + codeVerifier, + redirectUri, + params, + config + ); + + // スタブ呼び出し確認 + sinon.assert.calledOnceWithExactly(callbackStub, redirectUri, params, { + code_verifier: codeVerifier, + state: params.state, + }); + + expect(result).toMatch(/^Bearer /); + }); + + it('サインイン時にidTokenが取得できないエラー', async () => { + const codeVerifier = 'valid-code-verifier'; + const redirectUri = 'https://example.com/oidc/callback'; + const params = { state: 'state', code: 'auth-code' }; + const config = { + clientId: 'oidc-client-id', + clientSecret: 'oidc-client-secret', + configurationUrl: + 'https://example.com/.well-known/openid-configuration', + additionalScopes: [], + userHostedDomains: ['example.com'], + }; + + const mockTokenSet = { + access_token: 'xxxxx', + claims: (): IdTokenClaims => ({ + email: 'user@example.com', + sub: 'sub', + aud: 'aud', + exp: 1737455830, + iat: 1737455830, + iss: 'iss', + }), + expired: (): boolean => false, + }; + + const callbackStub = sandbox.stub().returns(mockTokenSet); + + // クライアントのモック + const mockClient = { + callback: callbackStub, + } as unknown as Client; + + // signinOidc関数を実行して結果を取得 + await assert.rejects( + signinOidc(mockClient, codeVerifier, redirectUri, params, config), + invalidOidcToken() + ); + + // スタブ呼び出し確認 + sinon.assert.calledOnceWithExactly(callbackStub, redirectUri, params, { + code_verifier: codeVerifier, + state: params.state, + }); + }); + + it('サインイン時にemailが取得できないエラー', async () => { + const codeVerifier = 'valid-code-verifier'; + const redirectUri = 'https://example.com/oidc/callback'; + const params = { state: 'state', code: 'auth-code' }; + const config = { + clientId: 'oidc-client-id', + clientSecret: 'oidc-client-secret', + configurationUrl: + 'https://example.com/.well-known/openid-configuration', + additionalScopes: [], + userHostedDomains: ['example.com'], + }; + + const mockTokenSet = { + access_token: 'xxxxx', + claims: (): IdTokenClaims => ({ + sub: 'sub', + aud: 'aud', + exp: 1737455830, + iat: 1737455830, + iss: 'iss', + }), + expired: (): boolean => false, + }; + + const callbackStub = sandbox.stub().returns(mockTokenSet); + + // クライアントのモック + const mockClient = { + callback: callbackStub, + } as unknown as Client; + + // signinOidc関数を実行して結果を取得 + await assert.rejects( + signinOidc(mockClient, codeVerifier, redirectUri, params, config), + invalidOidcToken() + ); + + // スタブ呼び出し確認 + sinon.assert.calledOnceWithExactly(callbackStub, redirectUri, params, { + code_verifier: codeVerifier, + state: params.state, + }); + }); + + it('サインイン時にidTokenの有効期限が切れているエラー', async () => { + const codeVerifier = 'valid-code-verifier'; + const redirectUri = 'https://example.com/oidc/callback'; + const params = { state: 'state', code: 'auth-code' }; + const config = { + clientId: 'oidc-client-id', + clientSecret: 'oidc-client-secret', + configurationUrl: + 'https://example.com/.well-known/openid-configuration', + additionalScopes: [], + userHostedDomains: ['example.com'], + }; + + const mockTokenSet = { + access_token: 'xxxxx', + id_token: 'yyyyy', + claims: (): IdTokenClaims => ({ + email: 'user@example.com', + sub: 'sub', + aud: 'aud', + exp: 1737455830, + iat: 1737455830, + iss: 'iss', + }), + expired: (): boolean => true, + }; + + const callbackStub = sandbox.stub().returns(mockTokenSet); + + // クライアントのモック + const mockClient = { + callback: callbackStub, + } as unknown as Client; + + // signinOidc関数を実行して結果を取得 + await assert.rejects( + signinOidc(mockClient, codeVerifier, redirectUri, params, config), + forbidden() + ); + + // スタブ呼び出し確認 + sinon.assert.calledOnceWithExactly(callbackStub, redirectUri, params, { + code_verifier: codeVerifier, + state: params.state, + }); + }); + + it('ビューアーユーザーの初回ログイン成功', async () => { + const codeVerifier = 'valid-code-verifier'; + const redirectUri = 'https://example.com/oidc/callback'; + const params = { state: 'state', code: 'auth-code' }; + const config = { + clientId: 'oidc-client-id', + clientSecret: 'oidc-client-secret', + configurationUrl: + 'https://example.com/.well-known/openid-configuration', + additionalScopes: [], + userHostedDomains: ['example.com'], + }; + + const mockTokenSet = { + access_token: 'xxxxx', + id_token: 'yyyyy', + claims: (): IdTokenClaims => ({ + email: 'viewer@example.com', + sub: 'sub', + aud: 'aud', + exp: 1737455830, + iat: 1737455830, + iss: 'iss', + }), + expired: (): boolean => false, + }; + + const callbackStub = sandbox.stub().returns(mockTokenSet); + + // クライアントのモック + const mockClient = { + callback: callbackStub, + } as unknown as Client; + + // すでに管理ユーザーがいる状態にする + sandbox.stub(repository, 'count').withArgs().resolves(1); + + // signinOidc関数を実行して結果を取得 + const result = await signinOidc( + mockClient, + codeVerifier, + redirectUri, + params, + config + ); + + // スタブ呼び出し確認 + sinon.assert.calledOnceWithExactly(callbackStub, redirectUri, params, { + code_verifier: codeVerifier, + state: params.state, + }); + + expect(result).toMatch(/^Bearer /); + + // ユーザーが正しく作成されたか確認 + const actual = await findOneByEmail( + mockTokenSet.claims().email as string + ); + assert.strictEqual(actual?.authType, AUTH_TYPE.OIDC); + assert.strictEqual(actual?.email, mockTokenSet.claims().email); + assert.strictEqual(actual?.oidcAccessToken, mockTokenSet.access_token); + assert.strictEqual(actual?.oidcIdToken, mockTokenSet.id_token); + + // ロールが正しく設定されているか確認 + const roleIds = await listRoles(actual?.id); + assert.strictEqual(roleIds[0], ADMIN_ROLE.VIEWER); + }); + + it('登録済みユーザーと認証タイプが違うエラー', async () => { + const codeVerifier = 'valid-code-verifier'; + const redirectUri = 'https://example.com/oidc/callback'; + const params = { state: 'state', code: 'auth-code' }; + const config = { + clientId: 'oidc-client-id', + clientSecret: 'oidc-client-secret', + configurationUrl: + 'https://example.com/.well-known/openid-configuration', + additionalScopes: [], + userHostedDomains: ['example.com'], + }; + + const mockTokenSet = { + access_token: 'xxxxx', + id_token: 'yyyyy', + claims: (): IdTokenClaims => ({ + email: 'registered@example.com', + sub: 'sub', + aud: 'aud', + exp: 1737455830, + iat: 1737455830, + iss: 'iss', + }), + expired: (): boolean => false, + }; + + const callbackStub = sandbox.stub().returns(mockTokenSet); + + // クライアントのモック + const mockClient = { + callback: callbackStub, + } as unknown as Client; + + // すでに同じemailでユーザーが存在する状態にする + await createOne( + { + email: mockTokenSet.claims().email as string, + oidcAccessToken: 'xxxxx', + oidcExpiryDate: 1737455830, + oidcIdToken: 'yyyyy', + oidcRefreshToken: null, + oidcTokenType: 'Bearer', + }, + AUTH_TYPE.GOOGLE + ); + + // signinOidc関数を実行して結果を取得 + await assert.rejects( + signinOidc(mockClient, codeVerifier, redirectUri, params, config), + signinFailed() + ); + + // スタブ呼び出し確認 + sinon.assert.calledOnceWithExactly(callbackStub, redirectUri, params, { + code_verifier: codeVerifier, + state: params.state, + }); + }); + + it('登録済みユーザーで再ログインが成功する', async () => { + const codeVerifier = 'valid-code-verifier'; + const redirectUri = 'https://example.com/oidc/callback'; + const params = { state: 'state', code: 'auth-code' }; + const config = { + clientId: 'oidc-client-id', + clientSecret: 'oidc-client-secret', + configurationUrl: + 'https://example.com/.well-known/openid-configuration', + additionalScopes: [], + userHostedDomains: ['example.com'], + }; + + const mockTokenSet = { + access_token: 'xxxxx_updated', + id_token: 'yyyyy_updated', + claims: (): IdTokenClaims => ({ + email: 'updated@example.com', + sub: 'sub', + aud: 'aud', + exp: 1737455830, + iat: 1737455830, + iss: 'iss', + }), + expired: (): boolean => false, + }; + + const callbackStub = sandbox.stub().returns(mockTokenSet); + + // クライアントのモック + const mockClient = { + callback: callbackStub, + } as unknown as Client; + + // すでに同じemailでユーザーが存在する状態にする + const registeredUser = await createOne( + { + email: mockTokenSet.claims().email as string, + oidcAccessToken: 'xxxxx', + oidcExpiryDate: 1737455830, + oidcIdToken: 'yyyyy', + oidcRefreshToken: null, + oidcTokenType: 'Bearer', + }, + AUTH_TYPE.OIDC + ); + await addRoleForUser(registeredUser.id, ADMIN_ROLE.VIEWER); + + // signinOidc関数を実行して結果を取得 + await signinOidc(mockClient, codeVerifier, redirectUri, params, config); + + // スタブ呼び出し確認 + sinon.assert.calledOnceWithExactly(callbackStub, redirectUri, params, { + code_verifier: codeVerifier, + state: params.state, + }); + + // ユーザーが正しく更新されたか確認 + const actual = await findOneByEmail( + mockTokenSet.claims().email as string + ); + assert.strictEqual(actual?.authType, AUTH_TYPE.OIDC); + assert.strictEqual(actual?.email, mockTokenSet.claims().email); + assert.strictEqual(actual?.oidcAccessToken, mockTokenSet.access_token); + assert.strictEqual(actual?.oidcIdToken, mockTokenSet.id_token); + + // ロールが正しく設定されているか確認 + const roleIds = await listRoles(actual?.id); + assert.strictEqual(roleIds[0], ADMIN_ROLE.VIEWER); + }); + }); + + describe('verifyOidcAccessToken', () => { + it('リフレッシュトークンがある場合にアクセストークンをリフレッシュする', async () => { + const mockTokenSet = { + access_token: 'xxxxx_updated', + id_token: 'yyyyy_updated', + refresh_token: 'zzzzz', + claims: (): IdTokenClaims => ({ + email: 'verify-access-token@example.com', + sub: 'sub', + aud: 'aud', + exp: 1737455830, + iat: 1737455830, + iss: 'iss', + }), + expired: (): boolean => false, + }; + + const refreshStub = sandbox.stub().returns(mockTokenSet); + + // クライアントのモック + const mockClient = { + refresh: refreshStub, + } as unknown as Client; + + // すでに同じemailでユーザーが存在する状態にする + const registeredUser = await createOne( + { + email: mockTokenSet.claims().email as string, + oidcAccessToken: 'xxxxx', + oidcExpiryDate: 1737455830, + oidcIdToken: 'yyyyy', + oidcRefreshToken: 'zzzzz', + oidcTokenType: 'Bearer', + }, + AUTH_TYPE.OIDC + ); + + // verifyOidcAccessToken関数を実行して結果を取得 + const result = await verifyOidcAccessToken( + mockClient, + registeredUser.id, + registeredUser + ); + + assert.strictEqual(result, true); + + // スタブ呼び出し確認 + sinon.assert.calledOnceWithExactly( + refreshStub, + registeredUser.oidcRefreshToken + ); + + // ユーザーが正しく更新されたか確認 + const actual = await findOneByEmail( + mockTokenSet.claims().email as string + ); + assert.strictEqual(actual?.authType, AUTH_TYPE.OIDC); + assert.strictEqual(actual?.email, mockTokenSet.claims().email); + assert.strictEqual(actual?.oidcAccessToken, mockTokenSet.access_token); + assert.strictEqual(actual?.oidcIdToken, mockTokenSet.id_token); + assert.strictEqual(actual?.oidcRefreshToken, mockTokenSet.refresh_token); + }); + it('リフレッシュトークンがない場合にintrospection_endpointでアクセストークン検証成功する', async () => { + const mockIntrospection = { + active: true, + }; + + const introspectStub = sandbox.stub().resolves(mockIntrospection); + + // クライアントのモック + const mockClient = { + introspect: introspectStub, + issuer: { + metadata: { + introspection_endpoint: 'https://example.com/introspection', + }, + }, + } as unknown as Client; + + const user = { + oidcAccessToken: 'xxxxx', + oidcExpiryDate: 1737455830, + oidcIdToken: 'yyyyy', + oidcRefreshToken: null, + oidcTokenType: 'Bearer', + }; + const userId = 'dummy'; + + // verifyOidcAccessToken関数を実行して結果を取得 + const result = await verifyOidcAccessToken(mockClient, userId, user); + + assert.strictEqual(result, true); + + // スタブ呼び出し確認 + sinon.assert.calledOnceWithExactly( + introspectStub, + user.oidcAccessToken, + 'access_token' + ); + }); + it('リフレッシュトークンがない場合にintrospection_endpointでアクセストークン検証してactive=falseでエラー', async () => { + const mockIntrospection = { + active: false, + }; + + const introspectStub = sandbox.stub().resolves(mockIntrospection); + + // クライアントのモック + const mockClient = { + introspect: introspectStub, + issuer: { + metadata: { + introspection_endpoint: 'https://example.com/introspection', + }, + }, + } as unknown as Client; + + const user = { + oidcAccessToken: 'xxxxx', + oidcExpiryDate: 1737455830, + oidcIdToken: 'yyyyy', + oidcRefreshToken: null, + oidcTokenType: 'Bearer', + }; + const userId = 'dummy'; + + // verifyOidcAccessToken関数を実行して結果を取得 + const result = await verifyOidcAccessToken(mockClient, userId, user); + + assert.strictEqual(result, false); + + // スタブ呼び出し確認 + sinon.assert.calledOnceWithExactly( + introspectStub, + user.oidcAccessToken, + 'access_token' + ); + }); + it('リフレッシュトークンがない場合にintrospection_endpointでアクセストークン検証して例外返却でエラー', async () => { + const mockIntrospection = new Error('introspect error'); + + const introspectStub = sandbox.stub().resolves(mockIntrospection); + + // クライアントのモック + const mockClient = { + introspect: introspectStub, + issuer: { + metadata: { + introspection_endpoint: 'https://example.com/introspection', + }, + }, + } as unknown as Client; + + const user = { + oidcAccessToken: 'xxxxx', + oidcExpiryDate: 1737455830, + oidcIdToken: 'yyyyy', + oidcRefreshToken: null, + oidcTokenType: 'Bearer', + }; + const userId = 'dummy'; + + // verifyOidcAccessToken関数を実行して結果を取得 + const result = await verifyOidcAccessToken(mockClient, userId, user); + + assert.strictEqual(result, false); + + // スタブ呼び出し確認 + sinon.assert.calledOnceWithExactly( + introspectStub, + user.oidcAccessToken, + 'access_token' + ); + }); + it('リフレッシュトークンがない場合にuserinfo_endpointでユーザー情報が取得できアクセストークンが有効', async () => { + const mockUserInfo = { + sub: 'dummy', + }; + + const userInfoStub = sandbox.stub().resolves(mockUserInfo); + + // クライアントのモック + const mockClient = { + userinfo: userInfoStub, + issuer: { + metadata: { + userinfo_endpoint: 'https://example.com/userinfo', + }, + }, + } as unknown as Client; + + const user = { + oidcAccessToken: 'xxxxx', + oidcExpiryDate: 1737455830, + oidcIdToken: 'yyyyy', + oidcRefreshToken: null, + oidcTokenType: 'Bearer', + }; + const userId = 'dummy'; + + // verifyOidcAccessToken関数を実行して結果を取得 + const result = await verifyOidcAccessToken(mockClient, userId, user); + + assert.strictEqual(result, true); + + // スタブ呼び出し確認 + sinon.assert.calledOnceWithExactly(userInfoStub, user.oidcAccessToken); + }); + it('リフレッシュトークンがない場合にuserinfo_endpointでユーザー情報取得できないエラー', async () => { + const userInfoStub = sandbox.stub().resolves(null); + + // クライアントのモック + const mockClient = { + userinfo: userInfoStub, + issuer: { + metadata: { + userinfo_endpoint: 'https://example.com/userinfo', + }, + }, + } as unknown as Client; + + const user = { + oidcAccessToken: 'xxxxx', + oidcExpiryDate: 1737455830, + oidcIdToken: 'yyyyy', + oidcRefreshToken: null, + oidcTokenType: 'Bearer', + }; + const userId = 'dummy'; + + // verifyOidcAccessToken関数を実行して結果を取得 + const result = await verifyOidcAccessToken(mockClient, userId, user); + + assert.strictEqual(result, false); + + // スタブ呼び出し確認 + sinon.assert.calledOnceWithExactly(userInfoStub, user.oidcAccessToken); + }); + it('リフレッシュトークンがない場合にuserinfo_endpointでユーザー情報取得で例外返却されたエラー', async () => { + const userInfoStub = sandbox.stub().resolves(new Error('userinfo error')); + + // クライアントのモック + const mockClient = { + userinfo: userInfoStub, + issuer: { + metadata: { + userinfo_endpoint: 'https://example.com/userinfo', + }, + }, + } as unknown as Client; + + const user = { + oidcAccessToken: 'xxxxx', + oidcExpiryDate: 1737455830, + oidcIdToken: 'yyyyy', + oidcRefreshToken: null, + oidcTokenType: 'Bearer', + }; + const userId = 'dummy'; + + // verifyOidcAccessToken関数を実行して結果を取得 + const result = await verifyOidcAccessToken(mockClient, userId, user); + + assert.strictEqual(result, false); + + // スタブ呼び出し確認 + sinon.assert.calledOnceWithExactly(userInfoStub, user.oidcAccessToken); + }); + }); +}); From d0f462d50503c4b36df7c48aa6b34cee32c18104 Mon Sep 17 00:00:00 2001 From: takoring Date: Thu, 23 Jan 2025 13:52:36 +0900 Subject: [PATCH 16/25] fix(nodejs): refactoring test code --- .../__tests__/domains/auth/oidc.test.ts | 445 +++++++++--------- 1 file changed, 218 insertions(+), 227 deletions(-) diff --git a/packages/nodejs/__tests__/domains/auth/oidc.test.ts b/packages/nodejs/__tests__/domains/auth/oidc.test.ts index 10a4f240a..3771f2121 100644 --- a/packages/nodejs/__tests__/domains/auth/oidc.test.ts +++ b/packages/nodejs/__tests__/domains/auth/oidc.test.ts @@ -1,6 +1,6 @@ import assert from 'assert'; import sinon from 'sinon'; -import { Issuer, Client, IdTokenClaims } from 'openid-client'; +import { generators, Issuer, Client, IdTokenClaims } from 'openid-client'; import { getOidcAuthorizationUrl, genOidcClient, @@ -14,59 +14,45 @@ import { forbidden, signinFailed, } from '../../../src/errors'; -import { - AdminUser, - AdminUserCreateAttributes, - AdminUserUpdateAttributes, - findOneByEmail, - createOne, -} from '../../../src/domains/adminuser'; +import { findOneByEmail, createOne } from '../../../src/domains/adminuser'; import { addRoleForUser, listRoles } from '../../../src/domains/adminrole'; import { AUTH_TYPE, ADMIN_ROLE } from '../../../src/constants'; -import { Repository, repositoryContainer } from '../../../src/repositories'; describe('domains/auth/oidc', () => { - const sandbox = sinon.createSandbox(); - - let repository: Repository< - AdminUser, - AdminUserCreateAttributes, - AdminUserUpdateAttributes - >; - - beforeAll(() => { - repository = repositoryContainer.getAdminUserRepository(); - }); + // 共有の設定 + const redirectUri = 'https://example.com/oidcredirect'; + const defaultConfig = { + clientId: 'oidc-client-id', + clientSecret: 'oidc-client-secret', + configurationUrl: 'https://example.com/.well-known/openid-configuration', + additionalScopes: [], + }; + const sandbox = sinon.createSandbox(); afterEach(() => { sandbox.restore(); }); describe('genOidcClient', () => { it('OIDCクライアントの生成に成功する', async () => { - const redirectUri = 'https://example.com/oidcredirect'; - const config = { - clientId: 'oidc-client-id', - clientSecret: 'oidc-client-secret', - configurationUrl: - 'https://example.com/.well-known/openid-configuration', - additionalScopes: ['email'], - }; + // デフォルト設定でのテスト + const config = defaultConfig; + // モック作成 const mockClient = {} as unknown as Client; const clientStub = sandbox.stub().returns(mockClient); - const mockIssuer = { metadata: { scopes_supported: ['openid', 'email'] }, Client: clientStub, } as unknown as Issuer; - const discoverStub = sandbox .stub(Issuer, 'discover') .resolves(mockIssuer); + // テスト対象の関数を呼び出し await genOidcClient(config, redirectUri); + // モックが期待通りに呼び出されたか確認 sinon.assert.calledOnceWithExactly(discoverStub, config.configurationUrl); sinon.assert.calledOnceWithExactly(clientStub, { client_id: config.clientId, @@ -76,50 +62,51 @@ describe('domains/auth/oidc', () => { }); }); it('サポートされてないscopeがある場合はエラー', async () => { - const redirectUri = 'https://example.com/oidcredirect'; + // 追加スコープにoffline_accessを追加 const config = { - clientId: 'oidc-client-id', - clientSecret: 'oidc-client-secret', - configurationUrl: - 'https://example.com/.well-known/openid-configuration', - additionalScopes: ['email', 'offline_access'], + ...defaultConfig, + additionalScopes: ['offline_access'], }; - const mockClient = {} as unknown as Client; - const clientStub = sandbox.stub().returns(mockClient); - + // モックの作成 const mockIssuer = { - metadata: { scopes_supported: ['openid', 'email'] }, // `unsupported-scope` はサポートされていない - Client: clientStub, + metadata: { scopes_supported: ['openid', 'email'] }, } as unknown as Issuer; - const discoverStub = sandbox .stub(Issuer, 'discover') .resolves(mockIssuer); + // テスト対象の関数を呼び出し await assert.rejects( genOidcClient(config, redirectUri), unsupportedScope() ); + // モックが期待通りに呼び出されたか確認 sinon.assert.calledOnceWithExactly(discoverStub, config.configurationUrl); - sinon.assert.notCalled(clientStub); // クライアント生成は呼び出されない }); }); describe('getOidcAuthorizationUrl', () => { it('OIDCのIdpへの認証画面URL生成に成功する', async () => { - const redirectUri = 'https://example.com/oidcredirect'; + // テストデータ const state = 'XXXXXXX'; + const scope = 'openid email'; + const responseType = 'code'; const codeVerifier = 'YYYYYYY'; - const config = { - clientId: 'oidc-client-id', - clientSecret: 'oidc-client-secret', - configurationUrl: - 'https://example.com/.well-known/openid-configuration', - additionalScopes: [], - }; - const authorizationUrl = `https://idp.jp/oidc/authz?client_id=${config.clientId}&scope=openid%20email&response_type=code&redirect_uri=https%3A%2F%2Fviron.work%2Foidcredirect&code_challenge=dummy&code_challenge_method=S256&state=${state}`; + const config = defaultConfig; + const codeChallengeMethod = 'S256'; + const codeChallenge = generators.codeChallenge(codeVerifier); + const authorizationEndpoint = 'https://idp.jp/oidc/authz'; + const url = new URL(authorizationEndpoint); + url.searchParams.append('client_id', config.clientId); + url.searchParams.append('scope', scope); + url.searchParams.append('response_type', responseType); + url.searchParams.append('redirect_uri', redirectUri); + url.searchParams.append('code_challenge', codeChallenge); + url.searchParams.append('code_challenge_method', codeChallengeMethod); + url.searchParams.append('state', state); + const authorizationUrl = url.toString(); // モックのClientを作成 const authorizationUrlStub = sandbox.stub().returns(authorizationUrl); @@ -131,8 +118,8 @@ describe('domains/auth/oidc', () => { const clientStub = sandbox.stub().returns(mockClient); const mockIssuer = { Client: clientStub, - metadata: {}, // 必須プロパティ - keystore: sandbox.stub(), // 必須プロパティ + metadata: {}, + keystore: sandbox.stub(), } as unknown as Issuer; // Issuer.discover をモック @@ -150,51 +137,51 @@ describe('domains/auth/oidc', () => { ); // モックが期待通りに呼び出されたか確認 - sinon.assert.calledOnceWithExactly( - discoverStub, - 'https://example.com/.well-known/openid-configuration' - ); + sinon.assert.calledOnceWithExactly(discoverStub, config.configurationUrl); sinon.assert.calledOnceWithExactly(clientStub, { - client_id: 'oidc-client-id', - client_secret: 'oidc-client-secret', + client_id: config.clientId, + client_secret: config.clientSecret, redirect_uris: [redirectUri], - response_types: ['code'], + response_types: [responseType], }); sinon.assert.calledOnceWithExactly(authorizationUrlStub, { - scope: 'openid email', + scope, state, - code_challenge: sinon.match.string, - code_challenge_method: 'S256', + code_challenge: codeChallenge, + code_challenge_method: codeChallengeMethod, }); // 結果の検証 assert.strictEqual(result, authorizationUrl); - const url = new URL(result); - assert.strictEqual(url.searchParams.get('state'), state); - assert.strictEqual(url.searchParams.get('client_id'), config.clientId); + const resultUrl = new URL(result); + assert.strictEqual(resultUrl.searchParams.get('state'), state); + assert.strictEqual( + resultUrl.searchParams.get('client_id'), + config.clientId + ); }); }); describe('signinOidc', () => { + // 共通の設定 + const codeVerifier = 'valid-code-verifier'; + const params = { state: 'state', code: 'auth-code' }; + beforeAll(() => { initJwt({ secret: 'test', provider: 'oidc', }); }); + it('OIDCのサインインに成功する', async () => { - const codeVerifier = 'valid-code-verifier'; - const redirectUri = 'https://example.com/oidc/callback'; - const params = { state: 'state', code: 'auth-code' }; + // テストデータ const config = { - clientId: 'oidc-client-id', - clientSecret: 'oidc-client-secret', - configurationUrl: - 'https://example.com/.well-known/openid-configuration', - additionalScopes: [], + ...defaultConfig, userHostedDomains: ['example.com'], }; + // モックの作成 const mockTokenSet = { access_token: 'xxxxx', id_token: 'yyyyy', @@ -208,15 +195,12 @@ describe('domains/auth/oidc', () => { }), expired: (): boolean => false, }; - const callbackStub = sandbox.stub().returns(mockTokenSet); - - // クライアントのモック const mockClient = { callback: callbackStub, } as unknown as Client; - // signinOidc関数を実行して結果を取得 + // テスト対象の関数を呼び出し const result = await signinOidc( mockClient, codeVerifier, @@ -231,22 +215,18 @@ describe('domains/auth/oidc', () => { state: params.state, }); + // 結果の検証 expect(result).toMatch(/^Bearer /); }); it('サインイン時にidTokenが取得できないエラー', async () => { - const codeVerifier = 'valid-code-verifier'; - const redirectUri = 'https://example.com/oidc/callback'; - const params = { state: 'state', code: 'auth-code' }; + // テストデータ const config = { - clientId: 'oidc-client-id', - clientSecret: 'oidc-client-secret', - configurationUrl: - 'https://example.com/.well-known/openid-configuration', - additionalScopes: [], + ...defaultConfig, userHostedDomains: ['example.com'], }; + // モックの作成 const mockTokenSet = { access_token: 'xxxxx', claims: (): IdTokenClaims => ({ @@ -259,15 +239,12 @@ describe('domains/auth/oidc', () => { }), expired: (): boolean => false, }; - const callbackStub = sandbox.stub().returns(mockTokenSet); - - // クライアントのモック const mockClient = { callback: callbackStub, } as unknown as Client; - // signinOidc関数を実行して結果を取得 + // テスト対象の関数を呼び出し await assert.rejects( signinOidc(mockClient, codeVerifier, redirectUri, params, config), invalidOidcToken() @@ -281,20 +258,16 @@ describe('domains/auth/oidc', () => { }); it('サインイン時にemailが取得できないエラー', async () => { - const codeVerifier = 'valid-code-verifier'; - const redirectUri = 'https://example.com/oidc/callback'; - const params = { state: 'state', code: 'auth-code' }; + // テストデータ const config = { - clientId: 'oidc-client-id', - clientSecret: 'oidc-client-secret', - configurationUrl: - 'https://example.com/.well-known/openid-configuration', - additionalScopes: [], + ...defaultConfig, userHostedDomains: ['example.com'], }; + // モックの作成 const mockTokenSet = { access_token: 'xxxxx', + id_token: 'yyyyy', claims: (): IdTokenClaims => ({ sub: 'sub', aud: 'aud', @@ -304,15 +277,12 @@ describe('domains/auth/oidc', () => { }), expired: (): boolean => false, }; - const callbackStub = sandbox.stub().returns(mockTokenSet); - - // クライアントのモック const mockClient = { callback: callbackStub, } as unknown as Client; - // signinOidc関数を実行して結果を取得 + // テスト対象の関数を呼び出し await assert.rejects( signinOidc(mockClient, codeVerifier, redirectUri, params, config), invalidOidcToken() @@ -325,19 +295,53 @@ describe('domains/auth/oidc', () => { }); }); + it('サインイン時にemailのドメインが許可対象外エラー', async () => { + // テストデータ + const config = { + ...defaultConfig, + userHostedDomains: ['no-example.com'], + }; + + // モックの作成 + const mockTokenSet = { + access_token: 'xxxxx', + id_token: 'yyyyy', + claims: (): IdTokenClaims => ({ + email: 'user@example.com', + sub: 'sub', + aud: 'aud', + exp: 1737455830, + iat: 1737455830, + iss: 'iss', + }), + expired: (): boolean => false, + }; + const callbackStub = sandbox.stub().returns(mockTokenSet); + const mockClient = { + callback: callbackStub, + } as unknown as Client; + + // テスト対象の関数を呼び出し + await assert.rejects( + signinOidc(mockClient, codeVerifier, redirectUri, params, config), + forbidden() + ); + + // スタブ呼び出し確認 + sinon.assert.calledOnceWithExactly(callbackStub, redirectUri, params, { + code_verifier: codeVerifier, + state: params.state, + }); + }); + it('サインイン時にidTokenの有効期限が切れているエラー', async () => { - const codeVerifier = 'valid-code-verifier'; - const redirectUri = 'https://example.com/oidc/callback'; - const params = { state: 'state', code: 'auth-code' }; + // テストデータ const config = { - clientId: 'oidc-client-id', - clientSecret: 'oidc-client-secret', - configurationUrl: - 'https://example.com/.well-known/openid-configuration', - additionalScopes: [], + ...defaultConfig, userHostedDomains: ['example.com'], }; + // モックの作成 const mockTokenSet = { access_token: 'xxxxx', id_token: 'yyyyy', @@ -351,15 +355,12 @@ describe('domains/auth/oidc', () => { }), expired: (): boolean => true, }; - const callbackStub = sandbox.stub().returns(mockTokenSet); - - // クライアントのモック const mockClient = { callback: callbackStub, } as unknown as Client; - // signinOidc関数を実行して結果を取得 + // テスト対象の関数を呼び出し await assert.rejects( signinOidc(mockClient, codeVerifier, redirectUri, params, config), forbidden() @@ -373,18 +374,13 @@ describe('domains/auth/oidc', () => { }); it('ビューアーユーザーの初回ログイン成功', async () => { - const codeVerifier = 'valid-code-verifier'; - const redirectUri = 'https://example.com/oidc/callback'; - const params = { state: 'state', code: 'auth-code' }; + // テストデータ const config = { - clientId: 'oidc-client-id', - clientSecret: 'oidc-client-secret', - configurationUrl: - 'https://example.com/.well-known/openid-configuration', - additionalScopes: [], + ...defaultConfig, userHostedDomains: ['example.com'], }; + // モックの作成 const mockTokenSet = { access_token: 'xxxxx', id_token: 'yyyyy', @@ -398,18 +394,26 @@ describe('domains/auth/oidc', () => { }), expired: (): boolean => false, }; - const callbackStub = sandbox.stub().returns(mockTokenSet); - // クライアントのモック const mockClient = { callback: callbackStub, } as unknown as Client; - // すでに管理ユーザーがいる状態にする - sandbox.stub(repository, 'count').withArgs().resolves(1); + // すでにsuperユーザーが存在する状態にする + await createOne( + { + email: 'super@example.com', + oidcAccessToken: 'xxxxx', + oidcExpiryDate: 1737455830, + oidcIdToken: 'yyyyy', + oidcRefreshToken: null, + oidcTokenType: 'Bearer', + }, + AUTH_TYPE.OIDC + ); - // signinOidc関数を実行して結果を取得 + // テスト対象の関数を呼び出し const result = await signinOidc( mockClient, codeVerifier, @@ -424,6 +428,7 @@ describe('domains/auth/oidc', () => { state: params.state, }); + // 結果の検証 expect(result).toMatch(/^Bearer /); // ユーザーが正しく作成されたか確認 @@ -441,18 +446,13 @@ describe('domains/auth/oidc', () => { }); it('登録済みユーザーと認証タイプが違うエラー', async () => { - const codeVerifier = 'valid-code-verifier'; - const redirectUri = 'https://example.com/oidc/callback'; - const params = { state: 'state', code: 'auth-code' }; + // テストデータ const config = { - clientId: 'oidc-client-id', - clientSecret: 'oidc-client-secret', - configurationUrl: - 'https://example.com/.well-known/openid-configuration', - additionalScopes: [], + ...defaultConfig, userHostedDomains: ['example.com'], }; + // モックの作成 const mockTokenSet = { access_token: 'xxxxx', id_token: 'yyyyy', @@ -466,10 +466,7 @@ describe('domains/auth/oidc', () => { }), expired: (): boolean => false, }; - const callbackStub = sandbox.stub().returns(mockTokenSet); - - // クライアントのモック const mockClient = { callback: callbackStub, } as unknown as Client; @@ -487,7 +484,7 @@ describe('domains/auth/oidc', () => { AUTH_TYPE.GOOGLE ); - // signinOidc関数を実行して結果を取得 + // テスト対象の関数を呼び出し await assert.rejects( signinOidc(mockClient, codeVerifier, redirectUri, params, config), signinFailed() @@ -501,18 +498,13 @@ describe('domains/auth/oidc', () => { }); it('登録済みユーザーで再ログインが成功する', async () => { - const codeVerifier = 'valid-code-verifier'; - const redirectUri = 'https://example.com/oidc/callback'; - const params = { state: 'state', code: 'auth-code' }; + // テストデータ const config = { - clientId: 'oidc-client-id', - clientSecret: 'oidc-client-secret', - configurationUrl: - 'https://example.com/.well-known/openid-configuration', - additionalScopes: [], + ...defaultConfig, userHostedDomains: ['example.com'], }; + // モックの作成 const mockTokenSet = { access_token: 'xxxxx_updated', id_token: 'yyyyy_updated', @@ -526,10 +518,7 @@ describe('domains/auth/oidc', () => { }), expired: (): boolean => false, }; - const callbackStub = sandbox.stub().returns(mockTokenSet); - - // クライアントのモック const mockClient = { callback: callbackStub, } as unknown as Client; @@ -574,6 +563,7 @@ describe('domains/auth/oidc', () => { describe('verifyOidcAccessToken', () => { it('リフレッシュトークンがある場合にアクセストークンをリフレッシュする', async () => { + // モックの作成 const mockTokenSet = { access_token: 'xxxxx_updated', id_token: 'yyyyy_updated', @@ -588,10 +578,7 @@ describe('domains/auth/oidc', () => { }), expired: (): boolean => false, }; - const refreshStub = sandbox.stub().returns(mockTokenSet); - - // クライアントのモック const mockClient = { refresh: refreshStub, } as unknown as Client; @@ -609,13 +596,14 @@ describe('domains/auth/oidc', () => { AUTH_TYPE.OIDC ); - // verifyOidcAccessToken関数を実行して結果を取得 + // テスト対象の関数を呼び出し const result = await verifyOidcAccessToken( mockClient, registeredUser.id, registeredUser ); + // 結果の検証 assert.strictEqual(result, true); // スタブ呼び出し確認 @@ -635,13 +623,21 @@ describe('domains/auth/oidc', () => { assert.strictEqual(actual?.oidcRefreshToken, mockTokenSet.refresh_token); }); it('リフレッシュトークンがない場合にintrospection_endpointでアクセストークン検証成功する', async () => { + // テストデータ + const user = { + oidcAccessToken: 'xxxxx', + oidcExpiryDate: 1737455830, + oidcIdToken: 'yyyyy', + oidcRefreshToken: null, + oidcTokenType: 'Bearer', + }; + const userId = 'dummy'; + + // モックの作成 const mockIntrospection = { active: true, }; - const introspectStub = sandbox.stub().resolves(mockIntrospection); - - // クライアントのモック const mockClient = { introspect: introspectStub, issuer: { @@ -651,18 +647,10 @@ describe('domains/auth/oidc', () => { }, } as unknown as Client; - const user = { - oidcAccessToken: 'xxxxx', - oidcExpiryDate: 1737455830, - oidcIdToken: 'yyyyy', - oidcRefreshToken: null, - oidcTokenType: 'Bearer', - }; - const userId = 'dummy'; - - // verifyOidcAccessToken関数を実行して結果を取得 + // テスト対象の関数を呼び出し const result = await verifyOidcAccessToken(mockClient, userId, user); + // 結果の検証 assert.strictEqual(result, true); // スタブ呼び出し確認 @@ -673,13 +661,22 @@ describe('domains/auth/oidc', () => { ); }); it('リフレッシュトークンがない場合にintrospection_endpointでアクセストークン検証してactive=falseでエラー', async () => { + // テストデータ + const user = { + oidcAccessToken: 'xxxxx', + oidcExpiryDate: 1737455830, + oidcIdToken: 'yyyyy', + oidcRefreshToken: null, + oidcTokenType: 'Bearer', + }; + const userId = 'dummy'; + const mockIntrospection = { active: false, }; + // モックの作成 const introspectStub = sandbox.stub().resolves(mockIntrospection); - - // クライアントのモック const mockClient = { introspect: introspectStub, issuer: { @@ -689,18 +686,10 @@ describe('domains/auth/oidc', () => { }, } as unknown as Client; - const user = { - oidcAccessToken: 'xxxxx', - oidcExpiryDate: 1737455830, - oidcIdToken: 'yyyyy', - oidcRefreshToken: null, - oidcTokenType: 'Bearer', - }; - const userId = 'dummy'; - - // verifyOidcAccessToken関数を実行して結果を取得 + // テスト対象の関数を呼び出し const result = await verifyOidcAccessToken(mockClient, userId, user); + // 結果の検証 assert.strictEqual(result, false); // スタブ呼び出し確認 @@ -711,11 +700,19 @@ describe('domains/auth/oidc', () => { ); }); it('リフレッシュトークンがない場合にintrospection_endpointでアクセストークン検証して例外返却でエラー', async () => { - const mockIntrospection = new Error('introspect error'); + // テストデータ + const user = { + oidcAccessToken: 'xxxxx', + oidcExpiryDate: 1737455830, + oidcIdToken: 'yyyyy', + oidcRefreshToken: null, + oidcTokenType: 'Bearer', + }; + const userId = 'dummy'; + // モックの作成 + const mockIntrospection = new Error('introspect error'); const introspectStub = sandbox.stub().resolves(mockIntrospection); - - // クライアントのモック const mockClient = { introspect: introspectStub, issuer: { @@ -725,18 +722,10 @@ describe('domains/auth/oidc', () => { }, } as unknown as Client; - const user = { - oidcAccessToken: 'xxxxx', - oidcExpiryDate: 1737455830, - oidcIdToken: 'yyyyy', - oidcRefreshToken: null, - oidcTokenType: 'Bearer', - }; - const userId = 'dummy'; - - // verifyOidcAccessToken関数を実行して結果を取得 + // テスト対象の関数を呼び出し const result = await verifyOidcAccessToken(mockClient, userId, user); + // 結果の検証 assert.strictEqual(result, false); // スタブ呼び出し確認 @@ -747,13 +736,21 @@ describe('domains/auth/oidc', () => { ); }); it('リフレッシュトークンがない場合にuserinfo_endpointでユーザー情報が取得できアクセストークンが有効', async () => { + // テストデータ + const user = { + oidcAccessToken: 'xxxxx', + oidcExpiryDate: 1737455830, + oidcIdToken: 'yyyyy', + oidcRefreshToken: null, + oidcTokenType: 'Bearer', + }; + const userId = 'dummy'; const mockUserInfo = { sub: 'dummy', }; + // モックの作成 const userInfoStub = sandbox.stub().resolves(mockUserInfo); - - // クライアントのモック const mockClient = { userinfo: userInfoStub, issuer: { @@ -763,6 +760,17 @@ describe('domains/auth/oidc', () => { }, } as unknown as Client; + // テスト対象の関数を呼び出し + const result = await verifyOidcAccessToken(mockClient, userId, user); + + // 結果の検証 + assert.strictEqual(result, true); + + // スタブ呼び出し確認 + sinon.assert.calledOnceWithExactly(userInfoStub, user.oidcAccessToken); + }); + it('リフレッシュトークンがない場合にuserinfo_endpointでユーザー情報取得できないエラー', async () => { + // テストデータ const user = { oidcAccessToken: 'xxxxx', oidcExpiryDate: 1737455830, @@ -772,18 +780,8 @@ describe('domains/auth/oidc', () => { }; const userId = 'dummy'; - // verifyOidcAccessToken関数を実行して結果を取得 - const result = await verifyOidcAccessToken(mockClient, userId, user); - - assert.strictEqual(result, true); - - // スタブ呼び出し確認 - sinon.assert.calledOnceWithExactly(userInfoStub, user.oidcAccessToken); - }); - it('リフレッシュトークンがない場合にuserinfo_endpointでユーザー情報取得できないエラー', async () => { + // モックの作成 const userInfoStub = sandbox.stub().resolves(null); - - // クライアントのモック const mockClient = { userinfo: userInfoStub, issuer: { @@ -793,6 +791,17 @@ describe('domains/auth/oidc', () => { }, } as unknown as Client; + // テスト対象の関数を呼び出し + const result = await verifyOidcAccessToken(mockClient, userId, user); + + // 結果の検証 + assert.strictEqual(result, false); + + // スタブ呼び出し確認 + sinon.assert.calledOnceWithExactly(userInfoStub, user.oidcAccessToken); + }); + it('リフレッシュトークンがない場合にuserinfo_endpointでユーザー情報取得で例外返却されたエラー', async () => { + // テストデータ const user = { oidcAccessToken: 'xxxxx', oidcExpiryDate: 1737455830, @@ -802,18 +811,8 @@ describe('domains/auth/oidc', () => { }; const userId = 'dummy'; - // verifyOidcAccessToken関数を実行して結果を取得 - const result = await verifyOidcAccessToken(mockClient, userId, user); - - assert.strictEqual(result, false); - - // スタブ呼び出し確認 - sinon.assert.calledOnceWithExactly(userInfoStub, user.oidcAccessToken); - }); - it('リフレッシュトークンがない場合にuserinfo_endpointでユーザー情報取得で例外返却されたエラー', async () => { + // モックの作成 const userInfoStub = sandbox.stub().resolves(new Error('userinfo error')); - - // クライアントのモック const mockClient = { userinfo: userInfoStub, issuer: { @@ -823,18 +822,10 @@ describe('domains/auth/oidc', () => { }, } as unknown as Client; - const user = { - oidcAccessToken: 'xxxxx', - oidcExpiryDate: 1737455830, - oidcIdToken: 'yyyyy', - oidcRefreshToken: null, - oidcTokenType: 'Bearer', - }; - const userId = 'dummy'; - - // verifyOidcAccessToken関数を実行して結果を取得 + // テスト対象の関数を呼び出し const result = await verifyOidcAccessToken(mockClient, userId, user); + // 結果の検証 assert.strictEqual(result, false); // スタブ呼び出し確認 From e959a84174e6c2ca84d1229ea02c15f42156bd8b Mon Sep 17 00:00:00 2001 From: takoring Date: Thu, 23 Jan 2025 14:21:44 +0900 Subject: [PATCH 17/25] fix(nodejs): development code removal --- example/nodejs/package.json | 3 +-- example/nodejs/src/config/local.ts | 1 - package-lock.json | 26 +------------------------- packages/nodejs/package.json | 1 - 4 files changed, 2 insertions(+), 29 deletions(-) diff --git a/example/nodejs/package.json b/example/nodejs/package.json index 596c32f20..30c6d9900 100644 --- a/example/nodejs/package.json +++ b/example/nodejs/package.json @@ -10,7 +10,6 @@ "compression": "^1.7.4", "cookie-parser": "^1.4.5", "cors": "^2.8.5", - "cross-env": "^7.0.3", "exegesis-express": "^2.0.1", "express": "^4.19.2", "i18next": "^21.6.7", @@ -70,7 +69,7 @@ "docker-compose:connect:mongo": "mongo -u root --authenticationDatabase admin mongodb://127.0.0.1:27017", "docker-compose:up:mysql": "docker-compose -f ./docker-compose.mysql.yml up", "docker-compose:up:mongo": "docker-compose -f ./docker-compose.mongo.yml up", - "dev": "cross-env $(cat .env | xargs) DEBUG=* ts-node-dev --watch src/openapi --debug --inspect=0.0.0.0:9229 --poll -- src/server.ts", + "dev": "DEBUG=* ts-node-dev --watch src/openapi --debug --inspect=0.0.0.0:9229 --poll -- src/server.ts", "lint": "npm run lint:ts", "lint:ts": "eslint \"{src,__tests__}/**/*.{ts,tsx}\"", "lint:ts:fix": "eslint --fix \"{src,__tests__}/**/*.{ts,tsx}\"", diff --git a/example/nodejs/src/config/local.ts b/example/nodejs/src/config/local.ts index b1b82d67f..46ed037aa 100644 --- a/example/nodejs/src/config/local.ts +++ b/example/nodejs/src/config/local.ts @@ -44,7 +44,6 @@ export const get = (mode: Mode): Config => { 'https://localhost:8000', 'https://viron.work', 'https://snapshot.viron.work', - 'https://viron.work:8000', ], }, auth: { diff --git a/package-lock.json b/package-lock.json index e1dbea2f1..dacb48126 100644 --- a/package-lock.json +++ b/package-lock.json @@ -36,7 +36,6 @@ "compression": "^1.7.4", "cookie-parser": "^1.4.5", "cors": "^2.8.5", - "cross-env": "^7.0.3", "exegesis-express": "^2.0.1", "express": "^4.19.2", "i18next": "^21.6.7", @@ -27250,23 +27249,6 @@ "devOptional": true, "license": "MIT" }, - "node_modules/cross-env": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz", - "integrity": "sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==", - "dependencies": { - "cross-spawn": "^7.0.1" - }, - "bin": { - "cross-env": "src/bin/cross-env.js", - "cross-env-shell": "src/bin/cross-env-shell.js" - }, - "engines": { - "node": ">=10.14", - "npm": ">=6", - "yarn": ">=1" - } - }, "node_modules/cross-fetch": { "version": "3.1.8", "license": "MIT", @@ -58897,7 +58879,6 @@ "lint-staged": "^12.3.1", "mongodb-memory-server": "^8.2.0", "openapi3-ts": "2.0.1", - "openid-client": "^4.7.4", "prettier": "^2.2.1", "sinon": "^12.0.1", "ts-jest": "^29.1.1", @@ -60001,7 +59982,6 @@ "version": "2.0.7", "resolved": "https://registry.npmjs.org/jose/-/jose-2.0.7.tgz", "integrity": "sha512-5hFWIigKqC+e/lRyQhfnirrAqUdIPMB7SJRqflJaO29dW7q5DFvH1XCSTmv6PQ6pb++0k6MJlLRoS0Wv4s38Wg==", - "dev": true, "dependencies": { "@panva/asn1.js": "^1.0.0" }, @@ -60035,7 +60015,6 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "dev": true, "dependencies": { "yallist": "^4.0.0" }, @@ -60124,7 +60103,6 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-2.2.0.tgz", "integrity": "sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==", - "dev": true, "engines": { "node": ">= 6" } @@ -60133,7 +60111,6 @@ "version": "4.7.4", "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-4.7.4.tgz", "integrity": "sha512-n+RURXYuR0bBZo9i0pn+CXZSyg5JYQ1nbwEwPQvLE7EcJt/vMZ2iIMjLehl5DvCN53XUoPVZs9KAE5r6d9fxsw==", - "dev": true, "dependencies": { "aggregate-error": "^3.1.0", "got": "^11.8.0", @@ -60334,8 +60311,7 @@ "packages/nodejs/node_modules/yallist": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" }, "packages/nodejs/node_modules/yargs-parser": { "version": "21.1.1", diff --git a/packages/nodejs/package.json b/packages/nodejs/package.json index ff6871b79..4adc252e8 100644 --- a/packages/nodejs/package.json +++ b/packages/nodejs/package.json @@ -60,7 +60,6 @@ "lint-staged": "^12.3.1", "mongodb-memory-server": "^8.2.0", "openapi3-ts": "2.0.1", - "openid-client": "^4.7.4", "prettier": "^2.2.1", "sinon": "^12.0.1", "ts-jest": "^29.1.1", From cdecca213b84e7ed608aa83dd700648724926e65 Mon Sep 17 00:00:00 2001 From: ejithon Date: Thu, 23 Jan 2025 17:32:46 +0900 Subject: [PATCH 18/25] =?UTF-8?q?refactor:=20buttons=20by=20sign-in=20prov?= =?UTF-8?q?ider=20=E2=99=BB=EF=B8=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/icon/login/outline/index.tsx | 2 - packages/app/src/locales/en/common.json | 5 +- packages/app/src/locales/ja/common.json | 6 +- .../dashboard/endpoints/_/body/item/index.tsx | 2 +- .../endpoints/_/body/item/signin/index.tsx | 112 ++++++++++-------- 5 files changed, 68 insertions(+), 59 deletions(-) delete mode 100644 packages/app/src/components/icon/login/outline/index.tsx diff --git a/packages/app/src/components/icon/login/outline/index.tsx b/packages/app/src/components/icon/login/outline/index.tsx deleted file mode 100644 index bafc758f0..000000000 --- a/packages/app/src/components/icon/login/outline/index.tsx +++ /dev/null @@ -1,2 +0,0 @@ -import { LoginIcon } from '@heroicons/react/outline'; -export default LoginIcon; diff --git a/packages/app/src/locales/en/common.json b/packages/app/src/locales/en/common.json index 40793aaf5..9de2de333 100644 --- a/packages/app/src/locales/en/common.json +++ b/packages/app/src/locales/en/common.json @@ -52,8 +52,9 @@ "importEndpoints": "Import $t(endpoints)", "enterEndpoint": "Enter", "signout": "Signout", - "oAuth": "OAuth", - "email": "Email", + "oidc": "SSO", + "oAuth": "Google", + "email": "Signin", "addEndpoint": { "title": "Create an $t(endpoint)", "idInputLabel": "ID", diff --git a/packages/app/src/locales/ja/common.json b/packages/app/src/locales/ja/common.json index fba36be1d..592c56782 100644 --- a/packages/app/src/locales/ja/common.json +++ b/packages/app/src/locales/ja/common.json @@ -52,9 +52,9 @@ "importEndpoints": "$t(endpoint)リストのインポート", "enterEndpoint": "管理画面", "signout": "サインアウト", - "oidc": "OIDC", - "oAuth": "OAuth", - "email": "Email", + "oidc": "SSO", + "oAuth": "Google", + "email": "サインイン", "addEndpoint": { "title": "$t(endpoint)の追加", "idInputLabel": "タイトル", diff --git a/packages/app/src/pages/dashboard/endpoints/_/body/item/index.tsx b/packages/app/src/pages/dashboard/endpoints/_/body/item/index.tsx index 7728e608e..9d912fea0 100644 --- a/packages/app/src/pages/dashboard/endpoints/_/body/item/index.tsx +++ b/packages/app/src/pages/dashboard/endpoints/_/body/item/index.tsx @@ -94,7 +94,7 @@ const Item: React.FC = ({ endpoint }) => { return (
= ({ endpoint, authentication }) => { }, [drawerEmail]); return ( - <> -
- {authConfigOidc && ( -
- - {authConfigOidc && ( - - )} - - - {authConfigOAuth && ( - - )} - - - {authConfigEmail && ( - - )} - - +
+ {authConfigEmail && ( + <> + + + + + + )} + {authConfigOAuth && ( + <> + + + + + + )} + {authConfigOidc && ( + <> + + + + + + )} +
); }; export default Signin; From 1d7043085435c8d73b2c5fd5b79f501594f3122b Mon Sep 17 00:00:00 2001 From: takoring Date: Thu, 23 Jan 2025 18:46:36 +0900 Subject: [PATCH 19/25] fix(nodejs): reducing refresh frequency and increasing coverage --- .../__tests__/domains/auth/oidc.test.ts | 358 +++++++++++++++++- packages/nodejs/src/constants.ts | 2 + packages/nodejs/src/domains/auth/oidc.ts | 34 +- 3 files changed, 382 insertions(+), 12 deletions(-) diff --git a/packages/nodejs/__tests__/domains/auth/oidc.test.ts b/packages/nodejs/__tests__/domains/auth/oidc.test.ts index 3771f2121..d45d4b60f 100644 --- a/packages/nodejs/__tests__/domains/auth/oidc.test.ts +++ b/packages/nodejs/__tests__/domains/auth/oidc.test.ts @@ -7,6 +7,7 @@ import { signinOidc, verifyOidcAccessToken, initJwt, + genOidcCodeVerifier, } from '../../../src/domains/auth'; import { unsupportedScope, @@ -14,7 +15,14 @@ import { forbidden, signinFailed, } from '../../../src/errors'; -import { findOneByEmail, createOne } from '../../../src/domains/adminuser'; +import { + findOneByEmail, + createOne, + AdminUser, + AdminUserCreateAttributes, + AdminUserUpdateAttributes, +} from '../../../src/domains/adminuser'; +import { Repository, repositoryContainer } from '../../../src/repositories'; import { addRoleForUser, listRoles } from '../../../src/domains/adminrole'; import { AUTH_TYPE, ADMIN_ROLE } from '../../../src/constants'; @@ -28,6 +36,16 @@ describe('domains/auth/oidc', () => { additionalScopes: [], }; + let repository: Repository< + AdminUser, + AdminUserCreateAttributes, + AdminUserUpdateAttributes + >; + + beforeAll(() => { + repository = repositoryContainer.getAdminUserRepository(); + }); + const sandbox = sinon.createSandbox(); afterEach(() => { sandbox.restore(); @@ -87,7 +105,150 @@ describe('domains/auth/oidc', () => { }); }); + describe('genOidcCodeVerifier', () => { + it('OIDCのcodeVerifierの生成', async () => { + const codeVerifierStub = sandbox + .stub(generators, 'codeVerifier') + .resolves('code-verifier'); + const result = await genOidcCodeVerifier(); + assert.strictEqual(result, 'code-verifier'); + sinon.assert.calledOnceWithExactly(codeVerifierStub); + }); + }); + describe('getOidcAuthorizationUrl', () => { + const state = 'XXXXXXX'; + const scope = 'openid email'; + const responseType = 'code'; + const codeVerifier = 'YYYYYYY'; + const config = defaultConfig; + const codeChallengeMethod = 'S256'; + const codeChallenge = generators.codeChallenge(codeVerifier); + const authorizationEndpoint = 'https://idp.jp/oidc/authz'; + const url = new URL(authorizationEndpoint); + url.searchParams.append('client_id', config.clientId); + url.searchParams.append('scope', scope); + url.searchParams.append('response_type', responseType); + url.searchParams.append('redirect_uri', redirectUri); + url.searchParams.append('code_challenge', codeChallenge); + url.searchParams.append('code_challenge_method', codeChallengeMethod); + url.searchParams.append('state', state); + const authorizationUrl = url.toString(); + it('OIDCのIdpへの認証画面URL生成に成功する additionalScopesあり', async () => { + // テストデータ + const config = { + ...defaultConfig, + additionalScopes: ['offline_access'], + }; + + // モックのClientを作成 + const authorizationUrlStub = sandbox.stub().returns(authorizationUrl); + const mockClient = { + authorizationUrl: authorizationUrlStub, + } as unknown as Client; + + // モックのIssuerを作成 + const clientStub = sandbox.stub().returns(mockClient); + const mockIssuer = { + Client: clientStub, + metadata: {}, + keystore: sandbox.stub(), + } as unknown as Issuer; + + // Issuer.discover をモック + const discoverStub = sandbox + .stub(Issuer, 'discover') + .resolves(mockIssuer); + + // テスト対象の関数を呼び出し + const client = await genOidcClient(config, redirectUri); + const result = await getOidcAuthorizationUrl( + config, + client, + codeVerifier, + state + ); + + // モックが期待通りに呼び出されたか確認 + sinon.assert.calledOnceWithExactly(discoverStub, config.configurationUrl); + sinon.assert.calledOnceWithExactly(clientStub, { + client_id: config.clientId, + client_secret: config.clientSecret, + redirect_uris: [redirectUri], + response_types: [responseType], + }); + sinon.assert.calledOnceWithExactly(authorizationUrlStub, { + scope: `${scope} offline_access`, + state, + code_challenge: codeChallenge, + code_challenge_method: codeChallengeMethod, + }); + + // 結果の検証 + assert.strictEqual(result, authorizationUrl); + const resultUrl = new URL(result); + assert.strictEqual(resultUrl.searchParams.get('state'), state); + assert.strictEqual( + resultUrl.searchParams.get('client_id'), + config.clientId + ); + }); + it('OIDCのIdpへの認証画面URL生成に成功する additionalScopesなし', async () => { + // テストデータ + const config = defaultConfig; + + // モックのClientを作成 + const authorizationUrlStub = sandbox.stub().returns(authorizationUrl); + const mockClient = { + authorizationUrl: authorizationUrlStub, + } as unknown as Client; + + // モックのIssuerを作成 + const clientStub = sandbox.stub().returns(mockClient); + const mockIssuer = { + Client: clientStub, + metadata: {}, + keystore: sandbox.stub(), + } as unknown as Issuer; + + // Issuer.discover をモック + const discoverStub = sandbox + .stub(Issuer, 'discover') + .resolves(mockIssuer); + + // テスト対象の関数を呼び出し + const client = await genOidcClient(config, redirectUri); + const result = await getOidcAuthorizationUrl( + config, + client, + codeVerifier, + state + ); + + // モックが期待通りに呼び出されたか確認 + sinon.assert.calledOnceWithExactly(discoverStub, config.configurationUrl); + sinon.assert.calledOnceWithExactly(clientStub, { + client_id: config.clientId, + client_secret: config.clientSecret, + redirect_uris: [redirectUri], + response_types: [responseType], + }); + sinon.assert.calledOnceWithExactly(authorizationUrlStub, { + scope, + state, + code_challenge: codeChallenge, + code_challenge_method: codeChallengeMethod, + }); + + // 結果の検証 + assert.strictEqual(result, authorizationUrl); + const resultUrl = new URL(result); + assert.strictEqual(resultUrl.searchParams.get('state'), state); + assert.strictEqual( + resultUrl.searchParams.get('client_id'), + config.clientId + ); + }); it('OIDCのIdpへの認証画面URL生成に成功する', async () => { // テストデータ const state = 'XXXXXXX'; @@ -174,7 +335,7 @@ describe('domains/auth/oidc', () => { }); }); - it('OIDCのサインインに成功する', async () => { + it('スーパーユーザーの初回ログインに成功する', async () => { // テストデータ const config = { ...defaultConfig, @@ -186,7 +347,7 @@ describe('domains/auth/oidc', () => { access_token: 'xxxxx', id_token: 'yyyyy', claims: (): IdTokenClaims => ({ - email: 'user@example.com', + email: 'super_user@example.com', sub: 'sub', aud: 'aud', exp: 1737455830, @@ -199,6 +360,7 @@ describe('domains/auth/oidc', () => { const mockClient = { callback: callbackStub, } as unknown as Client; + sandbox.stub(repository, 'count').withArgs().resolves(0); // テスト対象の関数を呼び出し const result = await signinOidc( @@ -217,6 +379,19 @@ describe('domains/auth/oidc', () => { // 結果の検証 expect(result).toMatch(/^Bearer /); + + // ユーザーが正しく作成されたか確認 + const actual = await findOneByEmail( + mockTokenSet.claims().email as string + ); + assert.strictEqual(actual?.authType, AUTH_TYPE.OIDC); + assert.strictEqual(actual?.email, mockTokenSet.claims().email); + assert.strictEqual(actual?.oidcAccessToken, mockTokenSet.access_token); + assert.strictEqual(actual?.oidcIdToken, mockTokenSet.id_token); + + // ロールが正しく設定されているか確認 + const roleIds = await listRoles(actual?.id); + assert.strictEqual(roleIds[0], ADMIN_ROLE.SUPER); }); it('サインイン時にidTokenが取得できないエラー', async () => { @@ -622,6 +797,155 @@ describe('domains/auth/oidc', () => { assert.strictEqual(actual?.oidcIdToken, mockTokenSet.id_token); assert.strictEqual(actual?.oidcRefreshToken, mockTokenSet.refresh_token); }); + it('リフレッシュトークンがある場合にアクセストークンをリフレッシュしてトークンがない場合エラー', async () => { + // モックの作成 + const mockTokenSet = { + access_token: 'xxxxx_updated', + id_token: 'yyyyy_updated', + refresh_token: 'zzzzz', + claims: (): IdTokenClaims => ({ + email: 'verify-access-token-no-token@example.com', + sub: 'sub', + aud: 'aud', + exp: 1737455830, + iat: 1737455830, + iss: 'iss', + }), + expired: (): boolean => false, + }; + const refreshStub = sandbox.stub().returns(null); + const mockClient = { + refresh: refreshStub, + } as unknown as Client; + + // すでに同じemailでユーザーが存在する状態にする + const registeredUser = await createOne( + { + email: mockTokenSet.claims().email as string, + oidcAccessToken: 'xxxxx', + oidcExpiryDate: 1737455830, + oidcIdToken: 'yyyyy', + oidcRefreshToken: 'zzzzz', + oidcTokenType: 'Bearer', + }, + AUTH_TYPE.OIDC + ); + + // テスト対象の関数を呼び出し + const result = await verifyOidcAccessToken( + mockClient, + registeredUser.id, + registeredUser + ); + + // 結果の検証 + assert.strictEqual(result, false); + + // スタブ呼び出し確認 + sinon.assert.calledOnceWithExactly( + refreshStub, + registeredUser.oidcRefreshToken + ); + + // ユーザーが更新されないこと確認 + const actual = await findOneByEmail( + mockTokenSet.claims().email as string + ); + assert.strictEqual(actual?.authType, AUTH_TYPE.OIDC); + assert.strictEqual(actual?.email, mockTokenSet.claims().email); + assert.strictEqual( + actual?.oidcAccessToken, + registeredUser.oidcAccessToken + ); + assert.strictEqual(actual?.oidcIdToken, registeredUser.oidcIdToken); + assert.strictEqual( + actual?.oidcRefreshToken, + registeredUser.oidcRefreshToken + ); + }); + it('リフレッシュトークンがある場合にアクセストークンの有効期限内はリフレッシュしない', async () => { + // モックの作成 + const mockTokenSet = { + access_token: 'xxxxx_updated', + id_token: 'yyyyy_updated', + refresh_token: 'zzzzz', + claims: (): IdTokenClaims => ({ + email: 'verify-access-token-no-refresh@example.com', + sub: 'sub', + aud: 'aud', + exp: 1737455830, + iat: 1737455830, + iss: 'iss', + }), + expired: (): boolean => false, + }; + const refreshStub = sandbox.stub().returns(mockTokenSet); + const mockClient = { + refresh: refreshStub, + } as unknown as Client; + + // すでに同じemailでユーザーが存在する状態にする + const registeredUser = await createOne( + { + email: mockTokenSet.claims().email as string, + oidcAccessToken: 'xxxxx', + oidcExpiryDate: Date.now() / 1000 + 10 * 60, // 10分後に有効期限切れ + oidcIdToken: 'yyyyy', + oidcRefreshToken: 'zzzzz', + oidcTokenType: 'Bearer', + }, + AUTH_TYPE.OIDC + ); + + // テスト対象の関数を呼び出し + const result = await verifyOidcAccessToken( + mockClient, + registeredUser.id, + registeredUser + ); + + // 結果の検証 + assert.strictEqual(result, true); + + // スタブ呼び出し確認 + sinon.assert.notCalled(refreshStub); + + // ユーザーが更新されていないことを確認 + const actual = await findOneByEmail( + mockTokenSet.claims().email as string + ); + assert.strictEqual(actual?.authType, AUTH_TYPE.OIDC); + assert.strictEqual(actual?.email, registeredUser.email); + assert.strictEqual( + actual?.oidcAccessToken, + registeredUser.oidcAccessToken + ); + assert.strictEqual(actual?.oidcIdToken, registeredUser.oidcIdToken); + assert.strictEqual( + actual?.oidcRefreshToken, + registeredUser.oidcRefreshToken + ); + }); + it('リフレッシュトークンとアクセストークンがない場合はエラー', async () => { + // テストデータ + const user = { + oidcAccessToken: null, + oidcExpiryDate: 1737455830, + oidcIdToken: 'yyyyy', + oidcRefreshToken: null, + oidcTokenType: 'Bearer', + }; + const userId = 'dummy'; + + // モックの作成 + const mockClient = {} as unknown as Client; + + // テスト対象の関数を呼び出し + const result = await verifyOidcAccessToken(mockClient, userId, user); + + // 結果の検証 + assert.strictEqual(result, false); + }); it('リフレッシュトークンがない場合にintrospection_endpointでアクセストークン検証成功する', async () => { // テストデータ const user = { @@ -712,7 +1036,7 @@ describe('domains/auth/oidc', () => { // モックの作成 const mockIntrospection = new Error('introspect error'); - const introspectStub = sandbox.stub().resolves(mockIntrospection); + const introspectStub = sandbox.stub().rejects(mockIntrospection); const mockClient = { introspect: introspectStub, issuer: { @@ -812,7 +1136,7 @@ describe('domains/auth/oidc', () => { const userId = 'dummy'; // モックの作成 - const userInfoStub = sandbox.stub().resolves(new Error('userinfo error')); + const userInfoStub = sandbox.stub().rejects(new Error('userinfo error')); const mockClient = { userinfo: userInfoStub, issuer: { @@ -831,5 +1155,29 @@ describe('domains/auth/oidc', () => { // スタブ呼び出し確認 sinon.assert.calledOnceWithExactly(userInfoStub, user.oidcAccessToken); }); + it('リフレッシュトークンがない場合にintrospection_endpointとuserinfo_endpointがサポートされてない場合はエラー', async () => { + // テストデータ + const user = { + oidcAccessToken: 'xxxxx', + oidcExpiryDate: 1737455830, + oidcIdToken: 'yyyyy', + oidcRefreshToken: null, + oidcTokenType: 'Bearer', + }; + const userId = 'dummy'; + + // モックの作成 + const mockClient = { + issuer: { + metadata: {}, + }, + } as unknown as Client; + + // テスト対象の関数を呼び出し + const result = await verifyOidcAccessToken(mockClient, userId, user); + + // 結果の検証 + assert.strictEqual(result, false); + }); }); }); diff --git a/packages/nodejs/src/constants.ts b/packages/nodejs/src/constants.ts index 22093c087..eabfc4963 100644 --- a/packages/nodejs/src/constants.ts +++ b/packages/nodejs/src/constants.ts @@ -143,6 +143,8 @@ export const GOOGLE_OAUTH2_DEFAULT_SCOPES = [ export const OIDC_DEFAULT_SCOPES = ['openid', 'email']; +export const OIDC_TOKEN_REFRESH_BEFORE_SEC = 5 * 60; // 5分前からリフレッシュを開始する + export const THEME = { RED: 'red', ULTIMATE_ORANGE: 'ultimate orange', diff --git a/packages/nodejs/src/domains/auth/oidc.ts b/packages/nodejs/src/domains/auth/oidc.ts index 1595d5877..2ad5ffdc5 100644 --- a/packages/nodejs/src/domains/auth/oidc.ts +++ b/packages/nodejs/src/domains/auth/oidc.ts @@ -17,7 +17,12 @@ import { import { createOne, findOneByEmail, updateOneById } from '../adminuser'; import { addRoleForUser } from '../adminrole'; import { createFirstAdminUser } from './common'; -import { ADMIN_ROLE, AUTH_TYPE, OIDC_DEFAULT_SCOPES } from '../../constants'; +import { + ADMIN_ROLE, + AUTH_TYPE, + OIDC_DEFAULT_SCOPES, + OIDC_TOKEN_REFRESH_BEFORE_SEC as OIDC_TOKEN_REFRESH_BUFFER_SEC, +} from '../../constants'; export interface OidcClientConfig { clientId: string; @@ -49,9 +54,10 @@ export const genOidcClient = async ( // scopes_supportedがある場合はチェック if (scopesSupported.length > 0) { // 追加スコープがある場合は追加スコープを含めてチェック - const scopes = config.additionalScopes - ? OIDC_DEFAULT_SCOPES.concat(config.additionalScopes) - : OIDC_DEFAULT_SCOPES; + const scopes = + config.additionalScopes && config.additionalScopes.length > 0 + ? OIDC_DEFAULT_SCOPES.concat(config.additionalScopes) + : OIDC_DEFAULT_SCOPES; // scopes の中のどれか一つでもサポートされていない場合はエラー if (scopes.some((scope) => !scopesSupported.includes(scope))) { throw unsupportedScope(); @@ -86,9 +92,10 @@ export const getOidcAuthorizationUrl = async ( // 認証URLを生成 const authorizationUrl = client.authorizationUrl({ - scope: config.additionalScopes - ? OIDC_DEFAULT_SCOPES.concat(config.additionalScopes).join(' ') - : OIDC_DEFAULT_SCOPES.join(' '), + scope: + config.additionalScopes && config.additionalScopes.length > 0 + ? OIDC_DEFAULT_SCOPES.concat(config.additionalScopes).join(' ') + : OIDC_DEFAULT_SCOPES.join(' '), code_challenge: codeChallenge, code_challenge_method: 'S256', state, @@ -278,6 +285,12 @@ export const verifyOidcAccessToken = async ( return false; } + // アクセストークンの有効期限が近い場合のみリフレッシュする + if (!isRefresh(credentials.oidcExpiryDate)) { + debug('verifyOidcAccessToken no need to refresh token. userId: %s', userId); + return true; + } + // リフレッシュトークンがある場合はリフレッシュトークンを使ってトークンを更新 const refreshToken = credentials.oidcRefreshToken; const tokenset = await client.refresh(refreshToken); @@ -309,3 +322,10 @@ const formatCredentials = (credentials: TokenSet): OidcCredentials => { oidcTokenType: credentials.token_type ?? null, }; }; + +const isRefresh = (expiryDate: number | null): boolean => { + return ( + !expiryDate || + Date.now() > (expiryDate - OIDC_TOKEN_REFRESH_BUFFER_SEC) * 1000 + ); +}; From bccebd038b371b462e9411e9ccc98fd699fda0b8 Mon Sep 17 00:00:00 2001 From: ejithon Date: Thu, 23 Jan 2025 19:04:41 +0900 Subject: [PATCH 20/25] =?UTF-8?q?fix:=20missing=20icons=20=F0=9F=90=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/app/src/components/button/index.tsx | 2 +- packages/app/src/components/google/index.tsx | 47 +++++++++++++++++++ .../src/components/icon/key/outline/index.tsx | 2 + .../components/icon/mail/outline/index.tsx | 2 + 4 files changed, 52 insertions(+), 1 deletion(-) create mode 100644 packages/app/src/components/google/index.tsx create mode 100644 packages/app/src/components/icon/key/outline/index.tsx create mode 100644 packages/app/src/components/icon/mail/outline/index.tsx diff --git a/packages/app/src/components/button/index.tsx b/packages/app/src/components/button/index.tsx index d18e16f8d..428705126 100644 --- a/packages/app/src/components/button/index.tsx +++ b/packages/app/src/components/button/index.tsx @@ -55,7 +55,7 @@ const Button = function ({ size = SIZE.SM, data, onClick, - rounded = variant === 'text' ? false : true, + rounded = variant !== 'text', }: React.PropsWithChildren>): JSX.Element { return (
-
+
-
- - +
+
+ + +
+
+ + +
+
+ + +
); diff --git a/packages/app/src/pages/dashboard/endpoints/_/body/item/signin/index.tsx b/packages/app/src/pages/dashboard/endpoints/_/body/item/signin/index.tsx index 6aefebd0a..ca49166b8 100644 --- a/packages/app/src/pages/dashboard/endpoints/_/body/item/signin/index.tsx +++ b/packages/app/src/pages/dashboard/endpoints/_/body/item/signin/index.tsx @@ -46,10 +46,10 @@ const Signin: React.FC = ({ endpoint, authentication }) => { }, [drawerEmail]); return ( -
+
{authConfigEmail && ( <> -