feat(oidc): for pre-exists users

This commit is contained in:
Kyush 2026-06-23 23:18:12 +09:00
commit 9b0b459731
21 changed files with 1056 additions and 71 deletions

View file

@ -2,6 +2,7 @@
### General
- Feat: 外部 OpenID Connect プロバイダー (Authentik 等) を用いた SSO ログインに対応 (設定ファイルの `sso.oidc`)
- Feat: 既存のアカウントの設定画面から外部 OIDC アカウントを連携・解除できるように
### Client
-

View file

@ -2414,6 +2414,20 @@ _2fa:
backupCodeUsedWarning: "A backup code has been used. Please reconfigure two-factor authentification as soon as possible if you are no longer able to use it."
backupCodesExhaustedWarning: "All backup codes have been used. Should you lose access to your two-factor authentification app, you will be unable to access this account. Please reconfigure two-factor authentification."
moreDetailedGuideHere: "Here is detailed guide"
_sso:
connectedAccounts: "Connected external accounts"
description: "Linking an external identity provider (OIDC) account to this account lets you sign in with that account as well."
link: "Link an external account"
linkProvider: "Link with {name}"
unlink: "Unlink"
unlinkConfirm: "Unlink this external account? You will no longer be able to sign in with it afterwards."
noLinkedAccounts: "There are no linked external accounts."
lastUsedAt: "Last used"
linked: "External account linked."
unlinked: "Unlinked."
linkFailed: "Failed to link the external account."
alreadyLinkedToOther: "This external account is already linked to another account."
backToSecuritySettings: "Back to security settings"
_permissions:
"read:account": "View your account information"
"write:account": "Edit your account information"

View file

@ -2467,6 +2467,21 @@ _2fa:
backupCodesExhaustedWarning: "バックアップコードが全て使用されました。認証アプリを利用できない場合、これ以上アカウントにアクセスできなくなります。認証アプリを再登録してください。"
moreDetailedGuideHere: "詳細なガイドはこちら"
_sso:
connectedAccounts: "連携済みの外部アカウント"
description: "外部のIDプロバイダーOIDCアカウントをこのアカウントに連携すると、そのアカウントでもログインできるようになります。"
link: "外部アカウントを連携"
linkProvider: "{name}と連携"
unlink: "連携を解除"
unlinkConfirm: "この外部アカウントの連携を解除しますか?解除後はこのアカウントでログインできなくなります。"
noLinkedAccounts: "連携済みの外部アカウントはありません。"
lastUsedAt: "最終使用"
linked: "外部アカウントを連携しました。"
unlinked: "連携を解除しました。"
linkFailed: "外部アカウントの連携に失敗しました。"
alreadyLinkedToOther: "この外部アカウントは既に別のアカウントに連携されています。"
backToSecuritySettings: "セキュリティ設定に戻る"
_permissions:
"read:account": "アカウントの情報を見る"
"write:account": "アカウントの情報を変更する"

View file

@ -2414,6 +2414,20 @@ _2fa:
backupCodeUsedWarning: "백업 코드가 사용되었습니다.인증 앱을 사용할 수 없게 된 경우, 조속히 인증 앱을 다시 설정해 주십시오."
backupCodesExhaustedWarning: "백업 코드가 모두 사용되었습니다.인증 앱을 사용할 수 없는 경우 더 이상 계정에 액세스하는 것이 불가능합니다.인증 앱을 다시 등록해 주세요."
moreDetailedGuideHere: "여기에 자세한 설명이 있습니다"
_sso:
connectedAccounts: "연동된 외부 계정"
description: "외부 ID 공급자(OIDC) 계정을 이 계정에 연동하면 해당 계정으로도 로그인할 수 있게 됩니다."
link: "외부 계정 연동"
linkProvider: "{name}와(과) 연동"
unlink: "연동 해제"
unlinkConfirm: "이 외부 계정의 연동을 해제할까요? 해제하면 이 계정으로 로그인할 수 없게 됩니다."
noLinkedAccounts: "연동된 외부 계정이 없습니다."
lastUsedAt: "마지막 사용"
linked: "외부 계정을 연동했습니다."
unlinked: "연동을 해제했습니다."
linkFailed: "외부 계정 연동에 실패했습니다."
alreadyLinkedToOther: "이 외부 계정은 이미 다른 계정에 연동되어 있습니다."
backToSecuritySettings: "보안 설정으로 돌아가기"
_permissions:
"read:account": "계정의 정보를 봅니다"
"write:account": "계정의 정보를 변경합니다"

View file

@ -55,6 +55,7 @@ import { RelayService } from './RelayService.js';
import { RoleService } from './RoleService.js';
import { S3Service } from './S3Service.js';
import { SignupService } from './SignupService.js';
import { SsoOidcService } from './SsoOidcService.js';
import { WebAuthnService } from './WebAuthnService.js';
import { UserBlockingService } from './UserBlockingService.js';
import { CacheService } from './CacheService.js';
@ -199,6 +200,7 @@ const $RelayService: Provider = { provide: 'RelayService', useExisting: RelaySer
const $RoleService: Provider = { provide: 'RoleService', useExisting: RoleService };
const $S3Service: Provider = { provide: 'S3Service', useExisting: S3Service };
const $SignupService: Provider = { provide: 'SignupService', useExisting: SignupService };
const $SsoOidcService: Provider = { provide: 'SsoOidcService', useExisting: SsoOidcService };
const $WebAuthnService: Provider = { provide: 'WebAuthnService', useExisting: WebAuthnService };
const $UserBlockingService: Provider = { provide: 'UserBlockingService', useExisting: UserBlockingService };
const $CacheService: Provider = { provide: 'CacheService', useExisting: CacheService };
@ -352,6 +354,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
RoleService,
S3Service,
SignupService,
SsoOidcService,
WebAuthnService,
UserBlockingService,
CacheService,
@ -502,6 +505,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
$RoleService,
$S3Service,
$SignupService,
$SsoOidcService,
$WebAuthnService,
$UserBlockingService,
$CacheService,
@ -652,6 +656,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
RoleService,
S3Service,
SignupService,
SsoOidcService,
WebAuthnService,
UserBlockingService,
CacheService,
@ -801,6 +806,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
$RoleService,
$S3Service,
$SignupService,
$SsoOidcService,
$WebAuthnService,
$UserBlockingService,
$CacheService,

View file

@ -0,0 +1,132 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Inject, Injectable } from '@nestjs/common';
import * as Redis from 'ioredis';
import * as oidc from 'openid-client';
import type { Config, SsoOidcConfig } from '@/config.js';
import { DI } from '@/di-symbols.js';
import { bindThis } from '@/decorators.js';
// state (CSRF nonce + PKCE verifier) lifetime: 5min, single-use
export const OIDC_STATE_TTL = 60 * 5;
export type OidcStateData = {
verifier: string;
nonce: string;
/**
* When set, the flow is a "link" flow: instead of signing in, the resolved
* identity is attached to this already-authenticated local user.
*/
userId?: string;
};
/**
* OIDC issuer discovery / authorization-request building shared by the Fastify
* SSO routes ({@link OidcClientService}) and the authenticated link API
* endpoints. Lives in CoreModule so endpoints can inject it; the signin /
* provisioning / handoff side effects stay in the Fastify adapter.
*/
@Injectable()
export class SsoOidcService {
#oidcConfigPromise: Promise<oidc.Configuration> | null = null;
constructor(
@Inject(DI.config)
private config: Config,
@Inject(DI.redis)
private redisClient: Redis.Redis,
) {
}
private get oidcConfig(): SsoOidcConfig | null {
const c = this.config.ssoOidc;
return c != null && c.enabled ? c : null;
}
@bindThis
public isAvailable(): boolean {
return this.oidcConfig != null;
}
private get callbackUrl(): string {
return `${this.config.url}/sso/oidc/callback`;
}
/**
* Lazily run OIDC issuer discovery so that an unreachable IdP at boot does
* not prevent the server from starting. The discovered Configuration is
* memoized; on failure the promise is cleared so the next request retries.
*/
@bindThis
public async getConfiguration(): Promise<oidc.Configuration> {
const oidcConf = this.oidcConfig;
if (oidcConf == null) {
throw new Error('OIDC SSO is not configured');
}
if (this.#oidcConfigPromise == null) {
this.#oidcConfigPromise = oidc.discovery(
new URL(oidcConf.issuer),
oidcConf.clientId,
oidcConf.clientSecret,
).catch((err) => {
this.#oidcConfigPromise = null;
throw err;
});
}
return this.#oidcConfigPromise;
}
/**
* Build an authorization request URL, persisting the per-request PKCE
* verifier + nonce (and, for link flows, the target userId) under a
* single-use state key in Redis.
*/
@bindThis
public async buildAuthorizationUrl(opts: { userId?: string }): Promise<string> {
const oidcConf = this.oidcConfig;
if (oidcConf == null) {
throw new Error('OIDC SSO is not configured');
}
const configuration = await this.getConfiguration();
const verifier = oidc.randomPKCECodeVerifier();
const challenge = await oidc.calculatePKCECodeChallenge(verifier);
const state = oidc.randomState();
const nonce = oidc.randomNonce();
await this.redisClient.setex(
`oidc:state:${state}`,
OIDC_STATE_TTL,
JSON.stringify({ verifier, nonce, userId: opts.userId } satisfies OidcStateData),
);
const authorizationUrl = oidc.buildAuthorizationUrl(configuration, {
redirect_uri: this.callbackUrl,
scope: oidcConf.scopes.join(' '),
code_challenge: challenge,
code_challenge_method: 'S256',
state,
nonce,
});
return authorizationUrl.href;
}
/**
* Atomically read + delete the state, so a given state can only be
* consumed once. Returns null when the state is unknown or expired.
*/
@bindThis
public async consumeState(state: string): Promise<OidcStateData | null> {
const raw = await this.redisClient.getdel(`oidc:state:${state}`);
if (!raw) return null;
return JSON.parse(raw) as OidcStateData;
}
}

View file

@ -292,6 +292,9 @@ export * as 'i/registry/scopes-with-domain' from './endpoints/i/registry/scopes-
export * as 'i/registry/set' from './endpoints/i/registry/set.js';
export * as 'i/revoke-token' from './endpoints/i/revoke-token.js';
export * as 'i/signin-history' from './endpoints/i/signin-history.js';
export * as 'i/sso/oidc/generate-link-url' from './endpoints/i/sso/oidc/generate-link-url.js';
export * as 'i/sso/oidc/list' from './endpoints/i/sso/oidc/list.js';
export * as 'i/sso/oidc/unlink' from './endpoints/i/sso/oidc/unlink.js';
export * as 'i/unpin' from './endpoints/i/unpin.js';
export * as 'i/update' from './endpoints/i/update.js';
export * as 'i/update-email' from './endpoints/i/update-email.js';

View file

@ -0,0 +1,101 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import bcrypt from 'bcryptjs';
import { Inject, Injectable } from '@nestjs/common';
import type { UserProfilesRepository } from '@/models/_.js';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { DI } from '@/di-symbols.js';
import { ApiError } from '@/server/api/error.js';
import { UserAuthService } from '@/core/UserAuthService.js';
import { SsoOidcService } from '@/core/SsoOidcService.js';
export const meta = {
requireCredential: true,
secure: true,
errors: {
incorrectPassword: {
message: 'Incorrect password.',
code: 'INCORRECT_PASSWORD',
id: '823ed611-2311-451d-9337-90752b85ae59',
},
unavailable: {
message: 'OIDC SSO is not available on this server.',
code: 'SSO_OIDC_UNAVAILABLE',
id: '0421fab7-215f-4444-b2a8-ec92898bc922',
},
unreachable: {
message: 'Failed to reach the identity provider.',
code: 'SSO_OIDC_UNREACHABLE',
id: '94c2a42c-bade-4f7e-8896-08ca73751e5c',
},
},
res: {
type: 'object',
optional: false, nullable: false,
properties: {
url: {
type: 'string',
optional: false, nullable: false,
},
},
},
} as const;
export const paramDef = {
type: 'object',
properties: {
password: { type: 'string' },
token: { type: 'string', nullable: true },
},
required: ['password'],
} as const;
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor(
@Inject(DI.userProfilesRepository)
private userProfilesRepository: UserProfilesRepository,
private userAuthService: UserAuthService,
private ssoOidcService: SsoOidcService,
) {
super(meta, paramDef, async (ps, me) => {
if (!this.ssoOidcService.isAvailable()) {
throw new ApiError(meta.errors.unavailable);
}
const profile = await this.userProfilesRepository.findOneByOrFail({ userId: me.id });
if (profile.twoFactorEnabled) {
if (ps.token == null) {
throw new Error('authentication failed');
}
try {
await this.userAuthService.twoFactorAuthenticate(profile, ps.token);
} catch (_) {
throw new Error('authentication failed');
}
}
const passwordMatched = await bcrypt.compare(ps.password, profile.password ?? '');
if (!passwordMatched) {
throw new ApiError(meta.errors.incorrectPassword);
}
let url: string;
try {
url = await this.ssoOidcService.buildAuthorizationUrl({ userId: me.id });
} catch {
throw new ApiError(meta.errors.unreachable);
}
return { url };
});
}
}

View file

@ -0,0 +1,77 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Inject, Injectable } from '@nestjs/common';
import type { UserSsoIdentitiesRepository } from '@/models/_.js';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { DI } from '@/di-symbols.js';
export const meta = {
requireCredential: true,
secure: true,
res: {
type: 'array',
optional: false, nullable: false,
items: {
type: 'object',
optional: false, nullable: false,
properties: {
id: {
type: 'string',
optional: false, nullable: false,
format: 'misskey:id',
},
createdAt: {
type: 'string',
optional: false, nullable: false,
format: 'date-time',
},
lastUsedAt: {
type: 'string',
optional: false, nullable: true,
format: 'date-time',
},
issuer: {
type: 'string',
optional: false, nullable: false,
},
sub: {
type: 'string',
optional: false, nullable: false,
},
},
},
},
} as const;
export const paramDef = {
type: 'object',
properties: {},
required: [],
} as const;
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor(
@Inject(DI.userSsoIdentitiesRepository)
private userSsoIdentitiesRepository: UserSsoIdentitiesRepository,
) {
super(meta, paramDef, async (ps, me) => {
const identities = await this.userSsoIdentitiesRepository.findBy({ userId: me.id });
return identities
.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime())
.map(identity => ({
id: identity.id,
createdAt: identity.createdAt.toISOString(),
lastUsedAt: identity.lastUsedAt ? identity.lastUsedAt.toISOString() : null,
issuer: identity.issuer,
sub: identity.sub,
}));
});
}
}

View file

@ -0,0 +1,81 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import bcrypt from 'bcryptjs';
import { Inject, Injectable } from '@nestjs/common';
import type { UserProfilesRepository, UserSsoIdentitiesRepository } from '@/models/_.js';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { DI } from '@/di-symbols.js';
import { ApiError } from '@/server/api/error.js';
import { UserAuthService } from '@/core/UserAuthService.js';
export const meta = {
requireCredential: true,
secure: true,
errors: {
incorrectPassword: {
message: 'Incorrect password.',
code: 'INCORRECT_PASSWORD',
id: '9f90b4a9-af39-4d53-9b37-a967d2a91ec6',
},
noSuchIdentity: {
message: 'No such SSO identity.',
code: 'NO_SUCH_SSO_IDENTITY',
id: '39e92893-8944-4dfa-9ac9-865004def6ba',
},
},
} as const;
export const paramDef = {
type: 'object',
properties: {
identityId: { type: 'string', format: 'misskey:id' },
password: { type: 'string' },
token: { type: 'string', nullable: true },
},
required: ['identityId', 'password'],
} as const;
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor(
@Inject(DI.userProfilesRepository)
private userProfilesRepository: UserProfilesRepository,
@Inject(DI.userSsoIdentitiesRepository)
private userSsoIdentitiesRepository: UserSsoIdentitiesRepository,
private userAuthService: UserAuthService,
) {
super(meta, paramDef, async (ps, me) => {
const profile = await this.userProfilesRepository.findOneByOrFail({ userId: me.id });
if (profile.twoFactorEnabled) {
if (ps.token == null) {
throw new Error('authentication failed');
}
try {
await this.userAuthService.twoFactorAuthenticate(profile, ps.token);
} catch (_) {
throw new Error('authentication failed');
}
}
const passwordMatched = await bcrypt.compare(ps.password, profile.password ?? '');
if (!passwordMatched) {
throw new ApiError(meta.errors.incorrectPassword);
}
const identity = await this.userSsoIdentitiesRepository.findOneBy({ id: ps.identityId, userId: me.id });
if (identity == null) {
throw new ApiError(meta.errors.noSuchIdentity);
}
await this.userSsoIdentitiesRepository.delete({ id: identity.id });
});
}
}

View file

@ -14,26 +14,19 @@ import type { UsersRepository, UserSsoIdentitiesRepository } from '@/models/_.js
import type { MiLocalUser } from '@/models/User.js';
import { IdService } from '@/core/IdService.js';
import { SignupService } from '@/core/SignupService.js';
import { SsoOidcService } from '@/core/SsoOidcService.js';
import { secureRndstr } from '@/misc/secure-rndstr.js';
import { LoggerService } from '@/core/LoggerService.js';
import Logger from '@/logger.js';
import { SigninService } from '@/server/api/SigninService.js';
import type { FastifyInstance, FastifyReply } from 'fastify';
// state (CSRF nonce + PKCE verifier) lifetime: 5min, single-use
const STATE_TTL = 60 * 5;
// one-time token handoff code lifetime: 2min, single-use
const HANDOFF_TTL = 60 * 2;
type OidcStateData = {
verifier: string;
nonce: string;
};
@Injectable()
export class OidcClientService {
private logger: Logger;
#oidcConfigPromise: Promise<oidc.Configuration> | null = null;
constructor(
@Inject(DI.config)
@ -51,6 +44,7 @@ export class OidcClientService {
private idService: IdService,
private signupService: SignupService,
private signinService: SigninService,
private ssoOidcService: SsoOidcService,
private loggerService: LoggerService,
) {
this.logger = this.loggerService.getLogger('oidc-client');
@ -61,36 +55,6 @@ export class OidcClientService {
return c != null && c.enabled ? c : null;
}
private get callbackUrl(): string {
return `${this.config.url}/sso/oidc/callback`;
}
/**
* Lazily run OIDC issuer discovery so that an unreachable IdP at boot does
* not prevent the server from starting. The discovered Configuration is
* memoized; on failure the promise is cleared so the next request retries.
*/
@bindThis
private async getConfiguration(): Promise<oidc.Configuration> {
const oidcConf = this.oidcConfig;
if (oidcConf == null) {
throw new Error('OIDC SSO is not configured');
}
if (this.#oidcConfigPromise == null) {
this.#oidcConfigPromise = oidc.discovery(
new URL(oidcConf.issuer),
oidcConf.clientId,
oidcConf.clientSecret,
).catch((err) => {
this.#oidcConfigPromise = null;
throw err;
});
}
return this.#oidcConfigPromise;
}
private replyError(reply: FastifyReply, status: number, message: string): void {
reply.header('Cache-Control', 'no-store');
reply.code(status);
@ -98,6 +62,17 @@ export class OidcClientService {
reply.send(message);
}
/**
* Bounce the browser back to the in-app redirect page with the outcome of a
* link flow. The user is already signed in, so there is no token handoff.
*/
private redirectLinkResult(reply: FastifyReply, result: 'success' | 'error', reason?: string): FastifyReply {
reply.header('Cache-Control', 'no-store');
const params = new URLSearchParams({ link: result });
if (reason != null) params.set('reason', reason);
return reply.redirect(`/sso/oidc/redirect?${params.toString()}`);
}
/**
* Derive a valid, unused local username from an OIDC claim for
* auto-provisioning. Local usernames must match /^\w{1,20}$/.
@ -131,36 +106,16 @@ export class OidcClientService {
return this.replyError(reply, 404, 'OIDC SSO is not enabled.');
}
let configuration: oidc.Configuration;
let authorizationUrl: string;
try {
configuration = await this.getConfiguration();
authorizationUrl = await this.ssoOidcService.buildAuthorizationUrl({});
} catch (err) {
this.logger.error('OIDC issuer discovery failed', { err });
return this.replyError(reply, 502, 'Failed to reach the identity provider.');
}
const verifier = oidc.randomPKCECodeVerifier();
const challenge = await oidc.calculatePKCECodeChallenge(verifier);
const state = oidc.randomState();
const nonce = oidc.randomNonce();
await this.redisClient.setex(
`oidc:state:${state}`,
STATE_TTL,
JSON.stringify({ verifier, nonce } satisfies OidcStateData),
);
const authorizationUrl = oidc.buildAuthorizationUrl(configuration, {
redirect_uri: this.callbackUrl,
scope: oidcConf.scopes.join(' '),
code_challenge: challenge,
code_challenge_method: 'S256',
state,
nonce,
});
reply.header('Cache-Control', 'no-store');
return reply.redirect(authorizationUrl.href);
return reply.redirect(authorizationUrl);
});
fastify.get('/callback', async (request, reply) => {
@ -175,16 +130,16 @@ export class OidcClientService {
return this.replyError(reply, 400, 'Missing state parameter.');
}
// getdel = atomic read + delete, so a state can only be consumed once.
const raw = await this.redisClient.getdel(`oidc:state:${state}`);
if (!raw) {
// consumeState = atomic read + delete, so a state can only be consumed once.
const stateData = await this.ssoOidcService.consumeState(state);
if (stateData == null) {
return this.replyError(reply, 403, 'Invalid or expired login session. Please try again.');
}
const { verifier, nonce } = JSON.parse(raw) as OidcStateData;
const { verifier, nonce, userId } = stateData;
let configuration: oidc.Configuration;
try {
configuration = await this.getConfiguration();
configuration = await this.ssoOidcService.getConfiguration();
} catch (err) {
this.logger.error('OIDC issuer discovery failed', { err });
return this.replyError(reply, 502, 'Failed to reach the identity provider.');
@ -209,9 +164,18 @@ export class OidcClientService {
sub = claims.sub;
} catch (err) {
this.logger.warn('OIDC code exchange / id_token validation failed', { err });
if (userId != null) {
return this.redirectLinkResult(reply, 'error', 'auth_failed');
}
return this.replyError(reply, 403, 'Authentication with the identity provider failed.');
}
// Link flow: attach this identity to the already-authenticated user
// rather than signing in / auto-provisioning.
if (userId != null) {
return this.handleLink(reply, issuer, sub, userId);
}
let identity = await this.userSsoIdentitiesRepository.findOneBy({ issuer, sub });
let user: MiLocalUser | null = null;
@ -296,4 +260,42 @@ export class OidcClientService {
// prefix (e.g. `/sso/oidc/redirect`) must fall through to the SPA
// handler so the frontend redirect page can render.
}
/**
* Attach a freshly authenticated OIDC identity to an existing local user.
* Idempotent when the identity is already linked to that same user; refuses
* when it belongs to someone else.
*/
@bindThis
private async handleLink(reply: FastifyReply, issuer: string, sub: string, userId: string): Promise<FastifyReply> {
const existing = await this.userSsoIdentitiesRepository.findOneBy({ issuer, sub });
if (existing != null) {
if (existing.userId === userId) {
await this.userSsoIdentitiesRepository.update({ id: existing.id }, { lastUsedAt: new Date() });
return this.redirectLinkResult(reply, 'success');
}
// The identity is already bound to a different account.
return this.redirectLinkResult(reply, 'error', 'already_linked');
}
const target = await this.usersRepository.findOneBy({ id: userId, host: IsNull() }) as MiLocalUser | null;
if (target == null) {
this.logger.warn(`Link flow target user ${userId} missing for issuer=${issuer} sub=${sub}`);
return this.redirectLinkResult(reply, 'error', 'no_user');
}
if (target.isSuspended) {
return this.redirectLinkResult(reply, 'error', 'suspended');
}
await this.userSsoIdentitiesRepository.insertOne({
id: this.idService.gen(),
createdAt: new Date(),
lastUsedAt: new Date(),
issuer,
sub,
userId,
});
this.logger.info(`Linked SSO identity issuer=${issuer} sub=${sub} to user ${userId}`);
return this.redirectLinkResult(reply, 'success');
}
}

View file

@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<SearchMarker path="/settings/security" :label="i18n.ts.security" :keywords="['security']" icon="ti ti-lock" :inlining="['2fa']">
<SearchMarker path="/settings/security" :label="i18n.ts.security" :keywords="['security']" icon="ti ti-lock" :inlining="['2fa', 'sso']">
<div class="_gaps_m">
<MkFeatureBanner icon="/client-assets/locked_with_key_3d.png" color="#ffbf00">
<SearchText>{{ i18n.ts._settings.securityBanner }}</SearchText>
@ -24,6 +24,8 @@ SPDX-License-Identifier: AGPL-3.0-only
<X2fa/>
<XSso/>
<SearchMarker :keywords="['signin', 'login', 'history', 'log']">
<FormSection>
<template #label><SearchLabel>{{ i18n.ts.signinHistory }}</SearchLabel></template>
@ -59,6 +61,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup>
import { computed, markRaw } from 'vue';
import X2fa from './2fa.vue';
import XSso from './sso.vue';
import FormSection from '@/components/form/section.vue';
import FormSlot from '@/components/form/slot.vue';
import MkButton from '@/components/MkButton.vue';

View file

@ -0,0 +1,151 @@
<!--
SPDX-FileCopyrightText: syuilo and misskey-project
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<SearchMarker v-if="instance.ssoOidcEnabled" markerId="sso" :keywords="['sso', 'oidc', 'oauth', 'login']">
<FormSection :first="first">
<template #label><SearchLabel>{{ i18n.ts._sso.connectedAccounts }}</SearchLabel></template>
<template #caption><SearchText>{{ i18n.ts._sso.description }}</SearchText></template>
<div class="_gaps_s">
<MkLoading v-if="fetching"/>
<template v-else>
<MkInfo v-if="identities.length === 0">{{ i18n.ts._sso.noLinkedAccounts }}</MkInfo>
<div v-else class="_gaps_s">
<div v-for="identity in identities" :key="identity.id" v-panel :class="$style.item">
<div :class="$style.itemBody">
<div :class="$style.itemName">{{ providerName }}</div>
<div :class="$style.itemSub">{{ identity.sub }}</div>
<div v-if="identity.lastUsedAt" :class="$style.itemMeta">
{{ i18n.ts._sso.lastUsedAt }}: <MkTime :time="identity.lastUsedAt"/>
</div>
</div>
<MkButton danger @click="unlink(identity)">{{ i18n.ts._sso.unlink }}</MkButton>
</div>
</div>
<MkButton primary @click="link">
<i class="ti ti-link"></i> {{ providerName ? i18n.tsx._sso.linkProvider({ name: providerName }) : i18n.ts._sso.link }}
</MkButton>
</template>
</div>
</FormSection>
</SearchMarker>
</template>
<script lang="ts" setup>
import { computed, onMounted, ref } from 'vue';
import FormSection from '@/components/form/section.vue';
import MkButton from '@/components/MkButton.vue';
import MkInfo from '@/components/MkInfo.vue';
import * as os from '@/os.js';
import { misskeyApi } from '@/utility/misskey-api.js';
import { instance } from '@/instance.js';
import { i18n } from '@/i18n.js';
type SsoIdentity = {
id: string;
createdAt: string;
lastUsedAt: string | null;
issuer: string;
sub: string;
};
withDefaults(defineProps<{
first?: boolean;
}>(), {
first: false,
});
const identities = ref<SsoIdentity[]>([]);
const fetching = ref(true);
const providerName = computed(() => instance.ssoOidcName ?? '');
async function refresh(): Promise<void> {
if (!instance.ssoOidcEnabled) return;
fetching.value = true;
try {
identities.value = await misskeyApi('i/sso/oidc/list', {});
} finally {
fetching.value = false;
}
}
async function link(): Promise<void> {
const auth = await os.authenticateDialog();
if (auth.canceled) return;
try {
const res = await os.apiWithDialog('i/sso/oidc/generate-link-url', {
password: auth.result.password,
token: auth.result.token,
});
// Hand the browser over to the identity provider; the callback returns to
// /sso/oidc/redirect, which reports the result.
window.location.href = res.url;
} catch {
// apiWithDialog already surfaced the error.
}
}
async function unlink(identity: SsoIdentity): Promise<void> {
const { canceled } = await os.confirm({
type: 'warning',
text: i18n.ts._sso.unlinkConfirm,
});
if (canceled) return;
const auth = await os.authenticateDialog();
if (auth.canceled) return;
try {
await os.apiWithDialog('i/sso/oidc/unlink', {
identityId: identity.id,
password: auth.result.password,
token: auth.result.token,
});
os.toast(i18n.ts._sso.unlinked);
await refresh();
} catch {
// apiWithDialog already surfaced the error.
}
}
onMounted(() => {
refresh();
});
</script>
<style lang="scss" module>
.item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
border-radius: var(--MI-radius);
}
.itemBody {
flex: 1;
min-width: 0;
}
.itemName {
font-weight: 700;
}
.itemSub {
font-size: 0.85em;
opacity: 0.8;
word-break: break-all;
}
.itemMeta {
margin-top: 4px;
font-size: 0.85em;
opacity: 0.7;
}
</style>

View file

@ -6,7 +6,17 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<div :class="$style.root">
<MkLoading v-if="state === 'loading'"/>
<div v-else-if="state === 'error'" class="_gaps_m" :class="$style.error">
<div v-else-if="state === 'linked'" class="_gaps_m" :class="$style.message">
<i class="ti ti-circle-check" :class="$style.successIcon"></i>
<div>{{ i18n.ts._sso.linked }}</div>
<MkButton primary rounded style="margin: 0 auto;" @click="backToSettings">{{ i18n.ts._sso.backToSecuritySettings }}</MkButton>
</div>
<div v-else-if="state === 'linkError'" class="_gaps_m" :class="$style.message">
<i class="ti ti-circle-x" :class="$style.errorIcon"></i>
<div>{{ linkErrorText }}</div>
<MkButton primary rounded style="margin: 0 auto;" @click="backToSettings">{{ i18n.ts._sso.backToSecuritySettings }}</MkButton>
</div>
<div v-else-if="state === 'error'" class="_gaps_m" :class="$style.message">
<i class="ti ti-circle-x" :class="$style.errorIcon"></i>
<div>{{ i18n.ts.signinFailed }}</div>
<MkButton primary rounded style="margin: 0 auto;" @click="retry">{{ i18n.ts.retry }}</MkButton>
@ -15,17 +25,29 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
<script lang="ts" setup>
import { onMounted, ref } from 'vue';
import { computed, onMounted, ref } from 'vue';
import MkButton from '@/components/MkButton.vue';
import { i18n } from '@/i18n.js';
import { login } from '@/accounts.js';
import { useRouter } from '@/router.js';
import { definePage } from '@/page.js';
const props = defineProps<{
session?: string;
link?: string;
reason?: string;
}>();
const state = ref<'loading' | 'error'>('loading');
const router = useRouter();
const state = ref<'loading' | 'error' | 'linked' | 'linkError'>('loading');
const linkErrorText = computed(() => {
switch (props.reason) {
case 'already_linked': return i18n.ts._sso.alreadyLinkedToOther;
default: return i18n.ts._sso.linkFailed;
}
});
async function exchange(): Promise<void> {
state.value = 'loading';
@ -62,7 +84,16 @@ function retry(): void {
window.location.href = '/sso/oidc/login';
}
function backToSettings(): void {
router.push('/settings/security');
}
onMounted(() => {
// The link flow lands here with a `link` result instead of a session code.
if (props.link != null) {
state.value = props.link === 'success' ? 'linked' : 'linkError';
return;
}
exchange();
});
@ -81,7 +112,7 @@ definePage(() => ({
box-sizing: border-box;
}
.error {
.message {
text-align: center;
}
@ -89,4 +120,9 @@ definePage(() => ({
font-size: 2.5em;
color: var(--MI_THEME-error);
}
.successIcon {
font-size: 2.5em;
color: var(--MI_THEME-success);
}
</style>

View file

@ -302,6 +302,8 @@ export const ROUTE_DEF = [{
component: page(() => import('@/pages/sso-oidc-redirect.vue')),
query: {
session: 'session',
link: 'link',
reason: 'reason',
},
}, {
path: '/tags/:tag',

View file

@ -9381,6 +9381,60 @@ export interface Locale extends ILocale {
*/
"moreDetailedGuideHere": string;
};
"_sso": {
/**
*
*/
"connectedAccounts": string;
/**
* IDプロバイダーOIDC
*/
"description": string;
/**
*
*/
"link": string;
/**
* {name}
*/
"linkProvider": ParameterizedString<"name">;
/**
*
*/
"unlink": string;
/**
*
*/
"unlinkConfirm": string;
/**
*
*/
"noLinkedAccounts": string;
/**
* 使
*/
"lastUsedAt": string;
/**
*
*/
"linked": string;
/**
*
*/
"unlinked": string;
/**
*
*/
"linkFailed": string;
/**
*
*/
"alreadyLinkedToOther": string;
/**
*
*/
"backToSecuritySettings": string;
};
"_permissions": {
/**
*

View file

@ -1994,6 +1994,10 @@ declare namespace entities {
IRevokeTokenRequest,
ISigninHistoryRequest,
ISigninHistoryResponse,
ISsoOidcGenerateLinkUrlRequest,
ISsoOidcGenerateLinkUrlResponse,
ISsoOidcListResponse,
ISsoOidcUnlinkRequest,
IUnpinRequest,
IUnpinResponse,
IUpdateRequest,
@ -2774,6 +2778,18 @@ type ISigninHistoryResponse = operations['i___signin-history']['responses']['200
// @public (undocumented)
function isPureRenote(note: Note): note is PureRenote;
// @public (undocumented)
type ISsoOidcGenerateLinkUrlRequest = operations['i___sso___oidc___generate-link-url']['requestBody']['content']['application/json'];
// @public (undocumented)
type ISsoOidcGenerateLinkUrlResponse = operations['i___sso___oidc___generate-link-url']['responses']['200']['content']['application/json'];
// @public (undocumented)
type ISsoOidcListResponse = operations['i___sso___oidc___list']['responses']['200']['content']['application/json'];
// @public (undocumented)
type ISsoOidcUnlinkRequest = operations['i___sso___oidc___unlink']['requestBody']['content']['application/json'];
// @public (undocumented)
export interface IStream extends EventEmitter<StreamEvents> {
// (undocumented)

View file

@ -3425,6 +3425,42 @@ declare module '../api.js' {
credential?: string | null,
): Promise<SwitchCaseResponseType<E, P>>;
/**
* No description provided.
*
* **Internal Endpoint**: This endpoint is an API for the misskey mainframe and is not intended for use by third parties.
* **Credential required**: *Yes*
*/
request<E extends 'i/sso/oidc/generate-link-url', P extends Endpoints[E]['req']>(
endpoint: E,
params: P,
credential?: string | null,
): Promise<SwitchCaseResponseType<E, P>>;
/**
* No description provided.
*
* **Internal Endpoint**: This endpoint is an API for the misskey mainframe and is not intended for use by third parties.
* **Credential required**: *Yes*
*/
request<E extends 'i/sso/oidc/list', P extends Endpoints[E]['req']>(
endpoint: E,
params: P,
credential?: string | null,
): Promise<SwitchCaseResponseType<E, P>>;
/**
* No description provided.
*
* **Internal Endpoint**: This endpoint is an API for the misskey mainframe and is not intended for use by third parties.
* **Credential required**: *Yes*
*/
request<E extends 'i/sso/oidc/unlink', P extends Endpoints[E]['req']>(
endpoint: E,
params: P,
credential?: string | null,
): Promise<SwitchCaseResponseType<E, P>>;
/**
* No description provided.
*

View file

@ -463,6 +463,10 @@ import type {
IRevokeTokenRequest,
ISigninHistoryRequest,
ISigninHistoryResponse,
ISsoOidcGenerateLinkUrlRequest,
ISsoOidcGenerateLinkUrlResponse,
ISsoOidcListResponse,
ISsoOidcUnlinkRequest,
IUnpinRequest,
IUnpinResponse,
IUpdateRequest,
@ -974,6 +978,9 @@ export type Endpoints = {
'i/registry/set': { req: IRegistrySetRequest; res: EmptyResponse };
'i/revoke-token': { req: IRevokeTokenRequest; res: EmptyResponse };
'i/signin-history': { req: ISigninHistoryRequest; res: ISigninHistoryResponse };
'i/sso/oidc/generate-link-url': { req: ISsoOidcGenerateLinkUrlRequest; res: ISsoOidcGenerateLinkUrlResponse };
'i/sso/oidc/list': { req: EmptyRequest; res: ISsoOidcListResponse };
'i/sso/oidc/unlink': { req: ISsoOidcUnlinkRequest; res: EmptyResponse };
'i/unpin': { req: IUnpinRequest; res: IUnpinResponse };
'i/update': { req: IUpdateRequest; res: IUpdateResponse };
'i/update-email': { req: IUpdateEmailRequest; res: IUpdateEmailResponse };

View file

@ -466,6 +466,10 @@ export type IRegistrySetRequest = operations['i___registry___set']['requestBody'
export type IRevokeTokenRequest = operations['i___revoke-token']['requestBody']['content']['application/json'];
export type ISigninHistoryRequest = operations['i___signin-history']['requestBody']['content']['application/json'];
export type ISigninHistoryResponse = operations['i___signin-history']['responses']['200']['content']['application/json'];
export type ISsoOidcGenerateLinkUrlRequest = operations['i___sso___oidc___generate-link-url']['requestBody']['content']['application/json'];
export type ISsoOidcGenerateLinkUrlResponse = operations['i___sso___oidc___generate-link-url']['responses']['200']['content']['application/json'];
export type ISsoOidcListResponse = operations['i___sso___oidc___list']['responses']['200']['content']['application/json'];
export type ISsoOidcUnlinkRequest = operations['i___sso___oidc___unlink']['requestBody']['content']['application/json'];
export type IUnpinRequest = operations['i___unpin']['requestBody']['content']['application/json'];
export type IUnpinResponse = operations['i___unpin']['responses']['200']['content']['application/json'];
export type IUpdateRequest = operations['i___update']['requestBody']['content']['application/json'];

View file

@ -2810,6 +2810,36 @@ export type paths = {
*/
post: operations['i___signin-history'];
};
'/i/sso/oidc/generate-link-url': {
/**
* i/sso/oidc/generate-link-url
* @description No description provided.
*
* **Internal Endpoint**: This endpoint is an API for the misskey mainframe and is not intended for use by third parties.
* **Credential required**: *Yes*
*/
post: operations['i___sso___oidc___generate-link-url'];
};
'/i/sso/oidc/list': {
/**
* i/sso/oidc/list
* @description No description provided.
*
* **Internal Endpoint**: This endpoint is an API for the misskey mainframe and is not intended for use by third parties.
* **Credential required**: *Yes*
*/
post: operations['i___sso___oidc___list'];
};
'/i/sso/oidc/unlink': {
/**
* i/sso/oidc/unlink
* @description No description provided.
*
* **Internal Endpoint**: This endpoint is an API for the misskey mainframe and is not intended for use by third parties.
* **Credential required**: *Yes*
*/
post: operations['i___sso___oidc___unlink'];
};
'/i/unpin': {
/**
* i/unpin
@ -27693,6 +27723,206 @@ export interface operations {
};
};
};
'i___sso___oidc___generate-link-url': {
requestBody: {
content: {
'application/json': {
password: string;
token?: string | null;
};
};
};
responses: {
/** @description OK (with results) */
200: {
headers: {
[name: string]: unknown;
};
content: {
'application/json': {
url: string;
};
};
};
/** @description Client error */
400: {
headers: {
[name: string]: unknown;
};
content: {
'application/json': components['schemas']['Error'];
};
};
/** @description Authentication error */
401: {
headers: {
[name: string]: unknown;
};
content: {
'application/json': components['schemas']['Error'];
};
};
/** @description Forbidden error */
403: {
headers: {
[name: string]: unknown;
};
content: {
'application/json': components['schemas']['Error'];
};
};
/** @description I'm Ai */
418: {
headers: {
[name: string]: unknown;
};
content: {
'application/json': components['schemas']['Error'];
};
};
/** @description Internal server error */
500: {
headers: {
[name: string]: unknown;
};
content: {
'application/json': components['schemas']['Error'];
};
};
};
};
i___sso___oidc___list: {
responses: {
/** @description OK (with results) */
200: {
headers: {
[name: string]: unknown;
};
content: {
'application/json': {
/** Format: misskey:id */
id: string;
/** Format: date-time */
createdAt: string;
/** Format: date-time */
lastUsedAt: string | null;
issuer: string;
sub: string;
}[];
};
};
/** @description Client error */
400: {
headers: {
[name: string]: unknown;
};
content: {
'application/json': components['schemas']['Error'];
};
};
/** @description Authentication error */
401: {
headers: {
[name: string]: unknown;
};
content: {
'application/json': components['schemas']['Error'];
};
};
/** @description Forbidden error */
403: {
headers: {
[name: string]: unknown;
};
content: {
'application/json': components['schemas']['Error'];
};
};
/** @description I'm Ai */
418: {
headers: {
[name: string]: unknown;
};
content: {
'application/json': components['schemas']['Error'];
};
};
/** @description Internal server error */
500: {
headers: {
[name: string]: unknown;
};
content: {
'application/json': components['schemas']['Error'];
};
};
};
};
i___sso___oidc___unlink: {
requestBody: {
content: {
'application/json': {
/** Format: misskey:id */
identityId: string;
password: string;
token?: string | null;
};
};
};
responses: {
/** @description OK (without any results) */
204: {
headers: {
[name: string]: unknown;
};
};
/** @description Client error */
400: {
headers: {
[name: string]: unknown;
};
content: {
'application/json': components['schemas']['Error'];
};
};
/** @description Authentication error */
401: {
headers: {
[name: string]: unknown;
};
content: {
'application/json': components['schemas']['Error'];
};
};
/** @description Forbidden error */
403: {
headers: {
[name: string]: unknown;
};
content: {
'application/json': components['schemas']['Error'];
};
};
/** @description I'm Ai */
418: {
headers: {
[name: string]: unknown;
};
content: {
'application/json': components['schemas']['Error'];
};
};
/** @description Internal server error */
500: {
headers: {
[name: string]: unknown;
};
content: {
'application/json': components['schemas']['Error'];
};
};
};
};
i___unpin: {
requestBody: {
content: {