diff --git a/packages/backend/migration/1710416761960-single-sign-on.js b/packages/backend/migration/1710416761960-single-sign-on.js deleted file mode 100644 index a24d3aab6e..0000000000 --- a/packages/backend/migration/1710416761960-single-sign-on.js +++ /dev/null @@ -1,15 +0,0 @@ -export class SingleSignOn1710416761960 { - name = 'SingleSignOn1710416761960' - - async up(queryRunner) { - await queryRunner.query(`CREATE TYPE "public"."sso_service_provider_type_enum" AS ENUM('saml', 'jwt')`); - await queryRunner.query(`CREATE TABLE "sso_service_provider" ("id" character varying(36) NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "name" character varying(256), "type" "public"."sso_service_provider_type_enum" NOT NULL, "issuer" character varying(512) NOT NULL, "audience" character varying(512) array NOT NULL DEFAULT '{}', "acsUrl" character varying(512) NOT NULL, "publicKey" character varying(4096) NOT NULL, "privateKey" character varying(4096), "signatureAlgorithm" character varying(100) NOT NULL, "cipherAlgorithm" character varying(100), "wantAuthnRequestsSigned" boolean NOT NULL DEFAULT false, "wantAssertionsSigned" boolean NOT NULL DEFAULT true, CONSTRAINT "PK_0e5fff64534026e48e1c248991a" PRIMARY KEY ("id"))`); - await queryRunner.query(`CREATE INDEX "IDX_86eee7fa4ae68e4a558dc50961" ON "sso_service_provider" ("createdAt") `); - } - - async down(queryRunner) { - await queryRunner.query(`DROP INDEX "public"."IDX_86eee7fa4ae68e4a558dc50961"`); - await queryRunner.query(`DROP TABLE "sso_service_provider"`); - await queryRunner.query(`DROP TYPE "public"."sso_service_provider_type_enum"`); - } -} diff --git a/packages/backend/migration/1710667213868-single-sign-on.js b/packages/backend/migration/1710667213868-single-sign-on.js new file mode 100644 index 0000000000..0f2095549d --- /dev/null +++ b/packages/backend/migration/1710667213868-single-sign-on.js @@ -0,0 +1,21 @@ +export class SingleSignOn1710667213868 { + name = 'SingleSignOn1710667213868' + + async up(queryRunner) { + await queryRunner.query(`DROP TABLE IF EXISTS "sso_service_provider"`); + await queryRunner.query(`DROP INDEX IF EXISTS "public"."IDX_86eee7fa4ae68e4a558dc50961"`); + await queryRunner.query(`DROP TYPE IF EXISTS "public"."sso_service_provider_binding_enum"`); + await queryRunner.query(`DROP TYPE IF EXISTS "public"."sso_service_provider_type_enum"`); + await queryRunner.query(`CREATE TYPE "public"."sso_service_provider_type_enum" AS ENUM('saml', 'jwt')`); + await queryRunner.query(`CREATE TYPE "public"."sso_service_provider_binding_enum" AS ENUM('post', 'redirect')`); + await queryRunner.query(`CREATE TABLE "sso_service_provider" ("id" character varying(36) NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "name" character varying(256), "type" "public"."sso_service_provider_type_enum" NOT NULL, "issuer" character varying(512) NOT NULL, "audience" character varying(512) array NOT NULL DEFAULT '{}', "binding" "public"."sso_service_provider_binding_enum" NOT NULL, "acsUrl" character varying(512) NOT NULL, "publicKey" character varying(4096) NOT NULL, "privateKey" character varying(4096), "signatureAlgorithm" character varying(100) NOT NULL, "cipherAlgorithm" character varying(100), "wantAuthnRequestsSigned" boolean NOT NULL DEFAULT false, "wantAssertionsSigned" boolean NOT NULL DEFAULT true, CONSTRAINT "PK_0e5fff64534026e48e1c248991a" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE INDEX "IDX_86eee7fa4ae68e4a558dc50961" ON "sso_service_provider" ("createdAt") `); + } + + async down(queryRunner) { + await queryRunner.query(`DROP INDEX "public"."IDX_86eee7fa4ae68e4a558dc50961"`); + await queryRunner.query(`DROP TABLE "sso_service_provider"`); + await queryRunner.query(`DROP TYPE "public"."sso_service_provider_binding_enum"`); + await queryRunner.query(`DROP TYPE "public"."sso_service_provider_type_enum"`); + } +} diff --git a/packages/backend/src/models/SingleSignOnServiceProvider.ts b/packages/backend/src/models/SingleSignOnServiceProvider.ts index c08db01cb1..03a095754c 100644 --- a/packages/backend/src/models/SingleSignOnServiceProvider.ts +++ b/packages/backend/src/models/SingleSignOnServiceProvider.ts @@ -39,6 +39,12 @@ export class MiSingleSignOnServiceProvider { }) public audience: string[]; + @Column('enum', { + enum: ['post', 'redirect'], + nullable: false, + }) + public binding: 'post' | 'redirect'; + @Column('varchar', { length: 512, }) diff --git a/packages/backend/src/server/api/endpoints/admin/indie-auth/create.ts b/packages/backend/src/server/api/endpoints/admin/indie-auth/create.ts index cfdda2231d..52ae6e326a 100644 --- a/packages/backend/src/server/api/endpoints/admin/indie-auth/create.ts +++ b/packages/backend/src/server/api/endpoints/admin/indie-auth/create.ts @@ -8,7 +8,7 @@ export const meta = { tags: ['admin'], requireCredential: true, - requireModerator: true, + requireAdmin: true, kind: 'write:admin:indie-auth', res: { diff --git a/packages/backend/src/server/api/endpoints/admin/indie-auth/delete.ts b/packages/backend/src/server/api/endpoints/admin/indie-auth/delete.ts index 681884af75..96f796b6e1 100644 --- a/packages/backend/src/server/api/endpoints/admin/indie-auth/delete.ts +++ b/packages/backend/src/server/api/endpoints/admin/indie-auth/delete.ts @@ -9,7 +9,7 @@ export const meta = { tags: ['admin'], requireCredential: true, - requireModerator: true, + requireAdmin: true, kind: 'write:admin:indie-auth', errors: { diff --git a/packages/backend/src/server/api/endpoints/admin/indie-auth/list.ts b/packages/backend/src/server/api/endpoints/admin/indie-auth/list.ts index 7f92577e95..32057e85f9 100644 --- a/packages/backend/src/server/api/endpoints/admin/indie-auth/list.ts +++ b/packages/backend/src/server/api/endpoints/admin/indie-auth/list.ts @@ -7,7 +7,7 @@ export const meta = { tags: ['admin'], requireCredential: true, - requireModerator: true, + requireAdmin: true, kind: 'read:admin:indie-auth', res: { diff --git a/packages/backend/src/server/api/endpoints/admin/indie-auth/update.ts b/packages/backend/src/server/api/endpoints/admin/indie-auth/update.ts index 5a913a8a6f..e2e80a9ce8 100644 --- a/packages/backend/src/server/api/endpoints/admin/indie-auth/update.ts +++ b/packages/backend/src/server/api/endpoints/admin/indie-auth/update.ts @@ -9,7 +9,7 @@ export const meta = { tags: ['admin'], requireCredential: true, - requireModerator: true, + requireAdmin: true, kind: 'write:admin:indie-auth', errors: { diff --git a/packages/backend/src/server/api/endpoints/admin/sso/create.ts b/packages/backend/src/server/api/endpoints/admin/sso/create.ts index 780fc59e74..6e7847db1d 100644 --- a/packages/backend/src/server/api/endpoints/admin/sso/create.ts +++ b/packages/backend/src/server/api/endpoints/admin/sso/create.ts @@ -11,7 +11,7 @@ export const meta = { tags: ['admin'], requireCredential: true, - requireModerator: true, + requireAdmin: true, kind: 'write:admin:sso', errors: { @@ -53,6 +53,11 @@ export const meta = { optional: false, nullable: false, items: { type: 'string', nullable: false }, }, + binding: { + type: 'string', + optional: false, nullable: false, + enum: ['post', 'redirect'], + }, acsUrl: { type: 'string', optional: false, nullable: false, @@ -88,6 +93,7 @@ export const paramDef = { type: { type: 'string', enum: ['saml', 'jwt'], nullable: false }, issuer: { type: 'string', nullable: false }, audience: { type: 'array', items: { type: 'string', nullable: false }, default: [] }, + binding: { type: 'string', enum: ['post', 'redirect'], nullable: false }, acsUrl: { type: 'string', nullable: false }, signatureAlgorithm: { type: 'string', nullable: false }, cipherAlgorithm: { type: 'string', nullable: true }, @@ -126,6 +132,7 @@ export default class extends Endpoint { // eslint- type: ps.type, issuer: ps.issuer, audience: ps.audience?.filter(i => i.length > 0) ?? [], + binding: ps.binding, acsUrl: ps.acsUrl, publicKey: publicKey, privateKey: privateKey, @@ -147,6 +154,7 @@ export default class extends Endpoint { // eslint- type: ssoServiceProvider.type, issuer: ssoServiceProvider.issuer, audience: ssoServiceProvider.audience, + binding: ssoServiceProvider.binding, acsUrl: ssoServiceProvider.acsUrl, publicKey: ssoServiceProvider.publicKey, signatureAlgorithm: ssoServiceProvider.signatureAlgorithm, diff --git a/packages/backend/src/server/api/endpoints/admin/sso/delete.ts b/packages/backend/src/server/api/endpoints/admin/sso/delete.ts index dc95e717ab..2234ebb3a0 100644 --- a/packages/backend/src/server/api/endpoints/admin/sso/delete.ts +++ b/packages/backend/src/server/api/endpoints/admin/sso/delete.ts @@ -9,7 +9,7 @@ export const meta = { tags: ['admin'], requireCredential: true, - requireModerator: true, + requireAdmin: true, kind: 'write:admin:sso', errors: { diff --git a/packages/backend/src/server/api/endpoints/admin/sso/list.ts b/packages/backend/src/server/api/endpoints/admin/sso/list.ts index e68cfdb73f..9adc9bc549 100644 --- a/packages/backend/src/server/api/endpoints/admin/sso/list.ts +++ b/packages/backend/src/server/api/endpoints/admin/sso/list.ts @@ -7,7 +7,7 @@ export const meta = { tags: ['admin'], requireCredential: true, - requireModerator: true, + requireAdmin: true, kind: 'read:admin:sso', res: { @@ -44,6 +44,11 @@ export const meta = { optional: false, nullable: false, items: { type: 'string', nullable: false }, }, + binding: { + type: 'string', + optional: false, nullable: false, + enum: ['post', 'redirect'], + }, acsUrl: { type: 'string', optional: false, nullable: false, @@ -103,6 +108,7 @@ export default class extends Endpoint { // eslint- type: service.type, issuer: service.issuer, audience: service.audience, + binding: service.binding, acsUrl: service.acsUrl, useCertificate: service.privateKey != null, publicKey: service.publicKey, diff --git a/packages/backend/src/server/api/endpoints/admin/sso/update.ts b/packages/backend/src/server/api/endpoints/admin/sso/update.ts index c72709f5ee..d0d4d153b8 100644 --- a/packages/backend/src/server/api/endpoints/admin/sso/update.ts +++ b/packages/backend/src/server/api/endpoints/admin/sso/update.ts @@ -10,7 +10,7 @@ export const meta = { tags: ['admin'], requireCredential: true, - requireModerator: true, + requireAdmin: true, kind: 'write:admin:sso', errors: { @@ -29,6 +29,7 @@ export const paramDef = { name: { type: 'string', nullable: true }, issuer: { type: 'string', nullable: false }, audience: { type: 'array', items: { type: 'string', nullable: false } }, + binding: { type: 'string', enum: ['post', 'redirect'], nullable: false }, acsUrl: { type: 'string', nullable: false }, signatureAlgorithm: { type: 'string', nullable: false }, cipherAlgorithm: { type: 'string', nullable: true }, @@ -65,6 +66,7 @@ export default class extends Endpoint { // eslint- name: ps.name !== '' ? ps.name : null, issuer: ps.issuer, audience: ps.audience?.filter(i => i.length > 0), + binding: ps.binding, acsUrl: ps.acsUrl, publicKey: publicKey, privateKey: privateKey, diff --git a/packages/backend/src/server/sso/JWTIdentifyProviderService.ts b/packages/backend/src/server/sso/JWTIdentifyProviderService.ts index 36b8e811ec..c639bec91d 100644 --- a/packages/backend/src/server/sso/JWTIdentifyProviderService.ts +++ b/packages/backend/src/server/sso/JWTIdentifyProviderService.ts @@ -104,16 +104,10 @@ export class JWTIdentifyProviderService { }); fastify.post<{ - Body: { transaction_id: string; login_token: string; cancel?: string }; + Body: { transaction_id: string; login_token: string; }; }>('/authorize', async (request, reply) => { const transactionId = request.body.transaction_id; const token = request.body.login_token; - const cancel = !!request.body.cancel; - - if (cancel) { - reply.redirect('/'); - return; - } const transaction = await this.redisClient.get(`sso:jwt:transaction:${transactionId}`); if (!transaction) { @@ -190,13 +184,14 @@ export class JWTIdentifyProviderService { roles: roles.filter(r => r.isPublic).map(r => r.id), }; + let jwt: string; try { if (ssoServiceProvider.cipherAlgorithm) { const key = ssoServiceProvider.publicKey.startsWith('{') ? await jose.importJWK(JSON.parse(ssoServiceProvider.publicKey)) : jose.base64url.decode(ssoServiceProvider.publicKey); - const jwt = await new jose.EncryptJWT(payload) + jwt = await new jose.EncryptJWT(payload) .setProtectedHeader({ alg: ssoServiceProvider.signatureAlgorithm, enc: ssoServiceProvider.cipherAlgorithm, @@ -208,31 +203,12 @@ export class JWTIdentifyProviderService { .setJti(randomUUID()) .setSubject(user.id) .encrypt(key); - - this.#logger.info(`Redirecting to "${ssoServiceProvider.acsUrl}"`, { - userId: user.id, - ssoServiceProvider: ssoServiceProvider.id, - acsUrl: ssoServiceProvider.acsUrl, - returnTo, - }); - - if (returnTo) { - reply.redirect( - `${ssoServiceProvider.acsUrl}?jwt=${jwt}&return_to=${returnTo}`, - ); - return; - } else { - reply.redirect( - `${ssoServiceProvider.acsUrl}?jwt=${jwt}`, - ); - return; - } } else { const key = ssoServiceProvider.privateKey ? await jose.importJWK(JSON.parse(ssoServiceProvider.privateKey)) : jose.base64url.decode(ssoServiceProvider.publicKey); - const jwt = await new jose.SignJWT(payload) + jwt = await new jose.SignJWT(payload) .setProtectedHeader({ alg: ssoServiceProvider.signatureAlgorithm }) .setIssuer(ssoServiceProvider.issuer) .setAudience(ssoServiceProvider.audience) @@ -241,25 +217,6 @@ export class JWTIdentifyProviderService { .setJti(randomUUID()) .setSubject(user.id) .sign(key); - - this.#logger.info(`Redirecting to "${ssoServiceProvider.acsUrl}"`, { - userId: user.id, - ssoServiceProvider: ssoServiceProvider.id, - acsUrl: ssoServiceProvider.acsUrl, - returnTo, - }); - - if (returnTo) { - reply.redirect( - `${ssoServiceProvider.acsUrl}?jwt=${jwt}&return_to=${returnTo}`, - ); - return; - } else { - reply.redirect( - `${ssoServiceProvider.acsUrl}?jwt=${jwt}`, - ); - return; - } } } catch (err) { this.#logger.error('Failed to create JWT', { error: err }); @@ -289,6 +246,30 @@ export class JWTIdentifyProviderService { } finally { await this.redisClient.del(`sso:jwt:transaction:${transactionId}`); } + + this.#logger.info(`User "${user.username}" authorized for "${ssoServiceProvider.name ?? ssoServiceProvider.issuer}"`); + reply.header('Cache-Control', 'no-store'); + switch (ssoServiceProvider.binding) { + case 'post': return reply + .status(200) + .send({ + binding: 'post', + action: ssoServiceProvider.acsUrl, + context: { + jwt, + return_to: returnTo ?? undefined, + }, + }); + + case 'redirect': return reply + .status(200) + .send({ + binding: 'redirect', + action: !returnTo + ? `${ssoServiceProvider.acsUrl}?jwt=${jwt}` + : `${ssoServiceProvider.acsUrl}?jwt=${jwt}&return_to=${returnTo}`, + }); + } }); } diff --git a/packages/backend/src/server/sso/SAMLIdentifyProviderService.ts b/packages/backend/src/server/sso/SAMLIdentifyProviderService.ts index fee6e9f74b..688127b27d 100644 --- a/packages/backend/src/server/sso/SAMLIdentifyProviderService.ts +++ b/packages/backend/src/server/sso/SAMLIdentifyProviderService.ts @@ -81,7 +81,7 @@ export class SAMLIdentifyProviderService { const nodes = { 'md:EntityDescriptor': { '@xmlns:md': 'urn:oasis:names:tc:SAML:2.0:metadata', - '@entityID': provider.issuer, + '@entityID': this.config.url, '@validUntil': tenYearsLater, 'md:IDPSSODescriptor': { '@WantAuthnRequestsSigned': provider.wantAuthnRequestsSigned, @@ -105,6 +105,10 @@ export class SAMLIdentifyProviderService { '@Binding': 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect', '@Location': `${this.config.url}/sso/saml/${provider.id}`, }, + { + '@Binding': 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST', + '@Location': `${this.config.url}/sso/saml/${provider.id}`, + }, ], }, }, @@ -185,13 +189,14 @@ export class SAMLIdentifyProviderService { 'md:NameIDFormat': { '#text': 'urn:oasis:names:tc:SAML:2.0:nameid-format:persistent', }, - 'md:AssertionConsumerService': [ - { - '@index': 1, - '@Binding': 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect', - '@Location': provider.acsUrl, - }, - ], + 'md:AssertionConsumerService': { + '@isDefault': 'true', + '@index': 0, + '@Binding': provider.binding === 'post' + ? 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST' + : 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect', + '@Location': provider.acsUrl, + }, }, }, }; @@ -235,7 +240,7 @@ export class SAMLIdentifyProviderService { Body?: { SAMLRequest?: string; RelayState?: string }; }>('/:serviceId', async (request, reply) => { const serviceId = request.params.serviceId; - const binding = 'redirect'; // 今はリダイレクトのみ対応 request.query?.SAMLRequest ? 'redirect' : 'post'; + const binding = request.query?.SAMLRequest ? 'redirect' : 'post'; const samlRequest = request.query?.SAMLRequest ?? request.body?.SAMLRequest; const relayState = request.query?.RelayState ?? request.body?.RelayState; @@ -284,7 +289,6 @@ export class SAMLIdentifyProviderService { `sso:saml:transaction:${transactionId}`, JSON.stringify({ serviceId: serviceId, - binding: binding, flowResult: parsed, relayState: relayState, }), @@ -350,16 +354,10 @@ export class SAMLIdentifyProviderService { ); fastify.post<{ - Body: { transaction_id: string; login_token: string; cancel?: string }; + Body: { transaction_id: string; login_token: string; }; }>('/authorize', async (request, reply) => { const transactionId = request.body.transaction_id; const token = request.body.login_token; - const cancel = !!request.body.cancel; - - if (cancel) { - reply.redirect('/'); - return; - } const transaction = await this.redisClient.get(`sso:saml:transaction:${transactionId}`); if (!transaction) { @@ -374,7 +372,7 @@ export class SAMLIdentifyProviderService { return; } - const { serviceId, binding, flowResult, relayState } = JSON.parse(transaction); + const { serviceId, flowResult, relayState } = JSON.parse(transaction); const ssoServiceProvider = await this.singleSignOnServiceProviderRepository.findOneBy({ id: serviceId, type: 'saml' }); @@ -439,7 +437,7 @@ export class SAMLIdentifyProviderService { const loginResponse = await idp.createLoginResponse( sp, flowResult, - binding, + ssoServiceProvider.binding, {}, () => { const id = idp.entitySetting.generateID?.() ?? randomUUID(); @@ -655,16 +653,27 @@ export class SAMLIdentifyProviderService { relayState, ); - this.#logger.info(`Redirecting to "${ssoServiceProvider.acsUrl}"`, { - userId: user.id, - ssoServiceProvider: ssoServiceProvider.id, - acsUrl: ssoServiceProvider.acsUrl, - relayState: relayState, - }); - + this.#logger.info(`User "${user.username}" authorized for "${ssoServiceProvider.name ?? ssoServiceProvider.issuer}"`); reply.header('Cache-Control', 'no-store'); - reply.redirect(loginResponse.context); - return; + switch (ssoServiceProvider.binding) { + case 'post': return reply + .status(200) + .send({ + binding: 'post', + action: ssoServiceProvider.acsUrl, + context: { + SAMLResponse: loginResponse.context, + RelayState: relayState ?? undefined, + }, + }); + + case 'redirect': return reply + .status(200) + .send({ + binding: 'redirect', + action: loginResponse.context, + }); + } } catch (err) { this.#logger.error('Failed to create SAML response', { error: err }); const traceableError = err as Error & { code?: string }; diff --git a/packages/frontend/src/pages/admin/security.vue b/packages/frontend/src/pages/admin/security.vue index c0be7f91bd..2944930f61 100644 --- a/packages/frontend/src/pages/admin/security.vue +++ b/packages/frontend/src/pages/admin/security.vue @@ -187,7 +187,7 @@ SPDX-License-Identifier: AGPL-3.0-only - + @@ -197,6 +197,10 @@ SPDX-License-Identifier: AGPL-3.0-only + + + + @@ -426,6 +430,7 @@ function ssoServiceAddNew() { type: 'jwt', issuer: '', audience: '', + binding: 'post', acsUrl: '', useCertificate: false, publicKey: '', @@ -457,6 +462,7 @@ async function ssoServiceSave(service) { type: service.type, issuer: service.issuer, audience: service.audience.split('\n'), + binding: service.binding, acsUrl: service.acsUrl, secret: service.publicKey, signatureAlgorithm: service.signatureAlgorithm, diff --git a/packages/frontend/src/pages/sso.vue b/packages/frontend/src/pages/sso.vue index 49a9c783d9..d47688bd92 100644 --- a/packages/frontend/src/pages/sso.vue +++ b/packages/frontend/src/pages/sso.vue @@ -7,15 +7,22 @@ SPDX-License-Identifier: AGPL-3.0-only -
+
{{ i18n.tsx._auth.shareAccess({ name }) }}
{{ i18n.ts._auth.shareAccessAsk }}
-
- - - {{ i18n.ts.cancel }} - {{ i18n.ts.accept }} -
+
+ {{ i18n.ts.cancel }} + {{ i18n.ts.accept }} +
+
+
+
{{ i18n.ts._auth.callback }}
+ +
+
+ +
+

{{ i18n.ts._auth.pleaseLogin }}

@@ -26,24 +33,63 @@ SPDX-License-Identifier: AGPL-3.0-only