forked from mirrors/misskey
feat(oidc): for pre-exists users
This commit is contained in:
parent
b55566ff96
commit
9b0b459731
21 changed files with 1056 additions and 71 deletions
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
### General
|
||||
- Feat: 外部 OpenID Connect プロバイダー (Authentik 等) を用いた SSO ログインに対応 (設定ファイルの `sso.oidc`)
|
||||
- Feat: 既存のアカウントの設定画面から外部 OIDC アカウントを連携・解除できるように
|
||||
|
||||
### Client
|
||||
-
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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": "アカウントの情報を変更する"
|
||||
|
|
|
|||
|
|
@ -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": "계정의 정보를 변경합니다"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
132
packages/backend/src/core/SsoOidcService.ts
Normal file
132
packages/backend/src/core/SsoOidcService.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
});
|
||||
}
|
||||
}
|
||||
77
packages/backend/src/server/api/endpoints/i/sso/oidc/list.ts
Normal file
77
packages/backend/src/server/api/endpoints/i/sso/oidc/list.ts
Normal 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,
|
||||
}));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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 });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
151
packages/frontend/src/pages/settings/sso.vue
Normal file
151
packages/frontend/src/pages/settings/sso.vue
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
/**
|
||||
* アカウントの情報を見る
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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'];
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue