enhance(SSO): SAML認証でHTTP-POSTバインディングに対応 (MisskeyIO#531)
This commit is contained in:
parent
27c897d19f
commit
aebe9ae148
16 changed files with 185 additions and 107 deletions
|
|
@ -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,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ export const meta = {
|
|||
tags: ['admin'],
|
||||
|
||||
requireCredential: true,
|
||||
requireModerator: true,
|
||||
requireAdmin: true,
|
||||
kind: 'write:admin:indie-auth',
|
||||
|
||||
res: {
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ export const meta = {
|
|||
tags: ['admin'],
|
||||
|
||||
requireCredential: true,
|
||||
requireModerator: true,
|
||||
requireAdmin: true,
|
||||
kind: 'write:admin:indie-auth',
|
||||
|
||||
errors: {
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ export const meta = {
|
|||
tags: ['admin'],
|
||||
|
||||
requireCredential: true,
|
||||
requireModerator: true,
|
||||
requireAdmin: true,
|
||||
kind: 'read:admin:indie-auth',
|
||||
|
||||
res: {
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ export const meta = {
|
|||
tags: ['admin'],
|
||||
|
||||
requireCredential: true,
|
||||
requireModerator: true,
|
||||
requireAdmin: true,
|
||||
kind: 'write:admin:indie-auth',
|
||||
|
||||
errors: {
|
||||
|
|
|
|||
|
|
@ -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<typeof meta, typeof paramDef> { // 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<typeof meta, typeof paramDef> { // eslint-
|
|||
type: ssoServiceProvider.type,
|
||||
issuer: ssoServiceProvider.issuer,
|
||||
audience: ssoServiceProvider.audience,
|
||||
binding: ssoServiceProvider.binding,
|
||||
acsUrl: ssoServiceProvider.acsUrl,
|
||||
publicKey: ssoServiceProvider.publicKey,
|
||||
signatureAlgorithm: ssoServiceProvider.signatureAlgorithm,
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ export const meta = {
|
|||
tags: ['admin'],
|
||||
|
||||
requireCredential: true,
|
||||
requireModerator: true,
|
||||
requireAdmin: true,
|
||||
kind: 'write:admin:sso',
|
||||
|
||||
errors: {
|
||||
|
|
|
|||
|
|
@ -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<typeof meta, typeof paramDef> { // eslint-
|
|||
type: service.type,
|
||||
issuer: service.issuer,
|
||||
audience: service.audience,
|
||||
binding: service.binding,
|
||||
acsUrl: service.acsUrl,
|
||||
useCertificate: service.privateKey != null,
|
||||
publicKey: service.publicKey,
|
||||
|
|
|
|||
|
|
@ -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<typeof meta, typeof paramDef> { // 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,
|
||||
|
|
|
|||
|
|
@ -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}`,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue