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
|
### General
|
||||||
- Feat: 外部 OpenID Connect プロバイダー (Authentik 等) を用いた SSO ログインに対応 (設定ファイルの `sso.oidc`)
|
- Feat: 外部 OpenID Connect プロバイダー (Authentik 等) を用いた SSO ログインに対応 (設定ファイルの `sso.oidc`)
|
||||||
|
- Feat: 既存のアカウントの設定画面から外部 OIDC アカウントを連携・解除できるように
|
||||||
|
|
||||||
### Client
|
### 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."
|
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."
|
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"
|
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:
|
_permissions:
|
||||||
"read:account": "View your account information"
|
"read:account": "View your account information"
|
||||||
"write:account": "Edit your account information"
|
"write:account": "Edit your account information"
|
||||||
|
|
|
||||||
|
|
@ -2467,6 +2467,21 @@ _2fa:
|
||||||
backupCodesExhaustedWarning: "バックアップコードが全て使用されました。認証アプリを利用できない場合、これ以上アカウントにアクセスできなくなります。認証アプリを再登録してください。"
|
backupCodesExhaustedWarning: "バックアップコードが全て使用されました。認証アプリを利用できない場合、これ以上アカウントにアクセスできなくなります。認証アプリを再登録してください。"
|
||||||
moreDetailedGuideHere: "詳細なガイドはこちら"
|
moreDetailedGuideHere: "詳細なガイドはこちら"
|
||||||
|
|
||||||
|
_sso:
|
||||||
|
connectedAccounts: "連携済みの外部アカウント"
|
||||||
|
description: "外部のIDプロバイダー(OIDC)アカウントをこのアカウントに連携すると、そのアカウントでもログインできるようになります。"
|
||||||
|
link: "外部アカウントを連携"
|
||||||
|
linkProvider: "{name}と連携"
|
||||||
|
unlink: "連携を解除"
|
||||||
|
unlinkConfirm: "この外部アカウントの連携を解除しますか?解除後はこのアカウントでログインできなくなります。"
|
||||||
|
noLinkedAccounts: "連携済みの外部アカウントはありません。"
|
||||||
|
lastUsedAt: "最終使用"
|
||||||
|
linked: "外部アカウントを連携しました。"
|
||||||
|
unlinked: "連携を解除しました。"
|
||||||
|
linkFailed: "外部アカウントの連携に失敗しました。"
|
||||||
|
alreadyLinkedToOther: "この外部アカウントは既に別のアカウントに連携されています。"
|
||||||
|
backToSecuritySettings: "セキュリティ設定に戻る"
|
||||||
|
|
||||||
_permissions:
|
_permissions:
|
||||||
"read:account": "アカウントの情報を見る"
|
"read:account": "アカウントの情報を見る"
|
||||||
"write:account": "アカウントの情報を変更する"
|
"write:account": "アカウントの情報を変更する"
|
||||||
|
|
|
||||||
|
|
@ -2414,6 +2414,20 @@ _2fa:
|
||||||
backupCodeUsedWarning: "백업 코드가 사용되었습니다.인증 앱을 사용할 수 없게 된 경우, 조속히 인증 앱을 다시 설정해 주십시오."
|
backupCodeUsedWarning: "백업 코드가 사용되었습니다.인증 앱을 사용할 수 없게 된 경우, 조속히 인증 앱을 다시 설정해 주십시오."
|
||||||
backupCodesExhaustedWarning: "백업 코드가 모두 사용되었습니다.인증 앱을 사용할 수 없는 경우 더 이상 계정에 액세스하는 것이 불가능합니다.인증 앱을 다시 등록해 주세요."
|
backupCodesExhaustedWarning: "백업 코드가 모두 사용되었습니다.인증 앱을 사용할 수 없는 경우 더 이상 계정에 액세스하는 것이 불가능합니다.인증 앱을 다시 등록해 주세요."
|
||||||
moreDetailedGuideHere: "여기에 자세한 설명이 있습니다"
|
moreDetailedGuideHere: "여기에 자세한 설명이 있습니다"
|
||||||
|
_sso:
|
||||||
|
connectedAccounts: "연동된 외부 계정"
|
||||||
|
description: "외부 ID 공급자(OIDC) 계정을 이 계정에 연동하면 해당 계정으로도 로그인할 수 있게 됩니다."
|
||||||
|
link: "외부 계정 연동"
|
||||||
|
linkProvider: "{name}와(과) 연동"
|
||||||
|
unlink: "연동 해제"
|
||||||
|
unlinkConfirm: "이 외부 계정의 연동을 해제할까요? 해제하면 이 계정으로 로그인할 수 없게 됩니다."
|
||||||
|
noLinkedAccounts: "연동된 외부 계정이 없습니다."
|
||||||
|
lastUsedAt: "마지막 사용"
|
||||||
|
linked: "외부 계정을 연동했습니다."
|
||||||
|
unlinked: "연동을 해제했습니다."
|
||||||
|
linkFailed: "외부 계정 연동에 실패했습니다."
|
||||||
|
alreadyLinkedToOther: "이 외부 계정은 이미 다른 계정에 연동되어 있습니다."
|
||||||
|
backToSecuritySettings: "보안 설정으로 돌아가기"
|
||||||
_permissions:
|
_permissions:
|
||||||
"read:account": "계정의 정보를 봅니다"
|
"read:account": "계정의 정보를 봅니다"
|
||||||
"write:account": "계정의 정보를 변경합니다"
|
"write:account": "계정의 정보를 변경합니다"
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,7 @@ import { RelayService } from './RelayService.js';
|
||||||
import { RoleService } from './RoleService.js';
|
import { RoleService } from './RoleService.js';
|
||||||
import { S3Service } from './S3Service.js';
|
import { S3Service } from './S3Service.js';
|
||||||
import { SignupService } from './SignupService.js';
|
import { SignupService } from './SignupService.js';
|
||||||
|
import { SsoOidcService } from './SsoOidcService.js';
|
||||||
import { WebAuthnService } from './WebAuthnService.js';
|
import { WebAuthnService } from './WebAuthnService.js';
|
||||||
import { UserBlockingService } from './UserBlockingService.js';
|
import { UserBlockingService } from './UserBlockingService.js';
|
||||||
import { CacheService } from './CacheService.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 $RoleService: Provider = { provide: 'RoleService', useExisting: RoleService };
|
||||||
const $S3Service: Provider = { provide: 'S3Service', useExisting: S3Service };
|
const $S3Service: Provider = { provide: 'S3Service', useExisting: S3Service };
|
||||||
const $SignupService: Provider = { provide: 'SignupService', useExisting: SignupService };
|
const $SignupService: Provider = { provide: 'SignupService', useExisting: SignupService };
|
||||||
|
const $SsoOidcService: Provider = { provide: 'SsoOidcService', useExisting: SsoOidcService };
|
||||||
const $WebAuthnService: Provider = { provide: 'WebAuthnService', useExisting: WebAuthnService };
|
const $WebAuthnService: Provider = { provide: 'WebAuthnService', useExisting: WebAuthnService };
|
||||||
const $UserBlockingService: Provider = { provide: 'UserBlockingService', useExisting: UserBlockingService };
|
const $UserBlockingService: Provider = { provide: 'UserBlockingService', useExisting: UserBlockingService };
|
||||||
const $CacheService: Provider = { provide: 'CacheService', useExisting: CacheService };
|
const $CacheService: Provider = { provide: 'CacheService', useExisting: CacheService };
|
||||||
|
|
@ -352,6 +354,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
|
||||||
RoleService,
|
RoleService,
|
||||||
S3Service,
|
S3Service,
|
||||||
SignupService,
|
SignupService,
|
||||||
|
SsoOidcService,
|
||||||
WebAuthnService,
|
WebAuthnService,
|
||||||
UserBlockingService,
|
UserBlockingService,
|
||||||
CacheService,
|
CacheService,
|
||||||
|
|
@ -502,6 +505,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
|
||||||
$RoleService,
|
$RoleService,
|
||||||
$S3Service,
|
$S3Service,
|
||||||
$SignupService,
|
$SignupService,
|
||||||
|
$SsoOidcService,
|
||||||
$WebAuthnService,
|
$WebAuthnService,
|
||||||
$UserBlockingService,
|
$UserBlockingService,
|
||||||
$CacheService,
|
$CacheService,
|
||||||
|
|
@ -652,6 +656,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
|
||||||
RoleService,
|
RoleService,
|
||||||
S3Service,
|
S3Service,
|
||||||
SignupService,
|
SignupService,
|
||||||
|
SsoOidcService,
|
||||||
WebAuthnService,
|
WebAuthnService,
|
||||||
UserBlockingService,
|
UserBlockingService,
|
||||||
CacheService,
|
CacheService,
|
||||||
|
|
@ -801,6 +806,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
|
||||||
$RoleService,
|
$RoleService,
|
||||||
$S3Service,
|
$S3Service,
|
||||||
$SignupService,
|
$SignupService,
|
||||||
|
$SsoOidcService,
|
||||||
$WebAuthnService,
|
$WebAuthnService,
|
||||||
$UserBlockingService,
|
$UserBlockingService,
|
||||||
$CacheService,
|
$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/registry/set' from './endpoints/i/registry/set.js';
|
||||||
export * as 'i/revoke-token' from './endpoints/i/revoke-token.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/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/unpin' from './endpoints/i/unpin.js';
|
||||||
export * as 'i/update' from './endpoints/i/update.js';
|
export * as 'i/update' from './endpoints/i/update.js';
|
||||||
export * as 'i/update-email' from './endpoints/i/update-email.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 type { MiLocalUser } from '@/models/User.js';
|
||||||
import { IdService } from '@/core/IdService.js';
|
import { IdService } from '@/core/IdService.js';
|
||||||
import { SignupService } from '@/core/SignupService.js';
|
import { SignupService } from '@/core/SignupService.js';
|
||||||
|
import { SsoOidcService } from '@/core/SsoOidcService.js';
|
||||||
import { secureRndstr } from '@/misc/secure-rndstr.js';
|
import { secureRndstr } from '@/misc/secure-rndstr.js';
|
||||||
import { LoggerService } from '@/core/LoggerService.js';
|
import { LoggerService } from '@/core/LoggerService.js';
|
||||||
import Logger from '@/logger.js';
|
import Logger from '@/logger.js';
|
||||||
import { SigninService } from '@/server/api/SigninService.js';
|
import { SigninService } from '@/server/api/SigninService.js';
|
||||||
import type { FastifyInstance, FastifyReply } from 'fastify';
|
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
|
// one-time token handoff code lifetime: 2min, single-use
|
||||||
const HANDOFF_TTL = 60 * 2;
|
const HANDOFF_TTL = 60 * 2;
|
||||||
|
|
||||||
type OidcStateData = {
|
|
||||||
verifier: string;
|
|
||||||
nonce: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class OidcClientService {
|
export class OidcClientService {
|
||||||
private logger: Logger;
|
private logger: Logger;
|
||||||
#oidcConfigPromise: Promise<oidc.Configuration> | null = null;
|
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@Inject(DI.config)
|
@Inject(DI.config)
|
||||||
|
|
@ -51,6 +44,7 @@ export class OidcClientService {
|
||||||
private idService: IdService,
|
private idService: IdService,
|
||||||
private signupService: SignupService,
|
private signupService: SignupService,
|
||||||
private signinService: SigninService,
|
private signinService: SigninService,
|
||||||
|
private ssoOidcService: SsoOidcService,
|
||||||
private loggerService: LoggerService,
|
private loggerService: LoggerService,
|
||||||
) {
|
) {
|
||||||
this.logger = this.loggerService.getLogger('oidc-client');
|
this.logger = this.loggerService.getLogger('oidc-client');
|
||||||
|
|
@ -61,36 +55,6 @@ export class OidcClientService {
|
||||||
return c != null && c.enabled ? c : null;
|
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 {
|
private replyError(reply: FastifyReply, status: number, message: string): void {
|
||||||
reply.header('Cache-Control', 'no-store');
|
reply.header('Cache-Control', 'no-store');
|
||||||
reply.code(status);
|
reply.code(status);
|
||||||
|
|
@ -98,6 +62,17 @@ export class OidcClientService {
|
||||||
reply.send(message);
|
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
|
* Derive a valid, unused local username from an OIDC claim for
|
||||||
* auto-provisioning. Local usernames must match /^\w{1,20}$/.
|
* 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.');
|
return this.replyError(reply, 404, 'OIDC SSO is not enabled.');
|
||||||
}
|
}
|
||||||
|
|
||||||
let configuration: oidc.Configuration;
|
let authorizationUrl: string;
|
||||||
try {
|
try {
|
||||||
configuration = await this.getConfiguration();
|
authorizationUrl = await this.ssoOidcService.buildAuthorizationUrl({});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.error('OIDC issuer discovery failed', { err });
|
this.logger.error('OIDC issuer discovery failed', { err });
|
||||||
return this.replyError(reply, 502, 'Failed to reach the identity provider.');
|
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');
|
reply.header('Cache-Control', 'no-store');
|
||||||
return reply.redirect(authorizationUrl.href);
|
return reply.redirect(authorizationUrl);
|
||||||
});
|
});
|
||||||
|
|
||||||
fastify.get('/callback', async (request, reply) => {
|
fastify.get('/callback', async (request, reply) => {
|
||||||
|
|
@ -175,16 +130,16 @@ export class OidcClientService {
|
||||||
return this.replyError(reply, 400, 'Missing state parameter.');
|
return this.replyError(reply, 400, 'Missing state parameter.');
|
||||||
}
|
}
|
||||||
|
|
||||||
// getdel = atomic read + delete, so a state can only be consumed once.
|
// consumeState = atomic read + delete, so a state can only be consumed once.
|
||||||
const raw = await this.redisClient.getdel(`oidc:state:${state}`);
|
const stateData = await this.ssoOidcService.consumeState(state);
|
||||||
if (!raw) {
|
if (stateData == null) {
|
||||||
return this.replyError(reply, 403, 'Invalid or expired login session. Please try again.');
|
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;
|
let configuration: oidc.Configuration;
|
||||||
try {
|
try {
|
||||||
configuration = await this.getConfiguration();
|
configuration = await this.ssoOidcService.getConfiguration();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.error('OIDC issuer discovery failed', { err });
|
this.logger.error('OIDC issuer discovery failed', { err });
|
||||||
return this.replyError(reply, 502, 'Failed to reach the identity provider.');
|
return this.replyError(reply, 502, 'Failed to reach the identity provider.');
|
||||||
|
|
@ -209,9 +164,18 @@ export class OidcClientService {
|
||||||
sub = claims.sub;
|
sub = claims.sub;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.warn('OIDC code exchange / id_token validation failed', { 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.');
|
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 identity = await this.userSsoIdentitiesRepository.findOneBy({ issuer, sub });
|
||||||
let user: MiLocalUser | null = null;
|
let user: MiLocalUser | null = null;
|
||||||
|
|
||||||
|
|
@ -296,4 +260,42 @@ export class OidcClientService {
|
||||||
// prefix (e.g. `/sso/oidc/redirect`) must fall through to the SPA
|
// prefix (e.g. `/sso/oidc/redirect`) must fall through to the SPA
|
||||||
// handler so the frontend redirect page can render.
|
// 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>
|
<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">
|
<div class="_gaps_m">
|
||||||
<MkFeatureBanner icon="/client-assets/locked_with_key_3d.png" color="#ffbf00">
|
<MkFeatureBanner icon="/client-assets/locked_with_key_3d.png" color="#ffbf00">
|
||||||
<SearchText>{{ i18n.ts._settings.securityBanner }}</SearchText>
|
<SearchText>{{ i18n.ts._settings.securityBanner }}</SearchText>
|
||||||
|
|
@ -24,6 +24,8 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
<X2fa/>
|
<X2fa/>
|
||||||
|
|
||||||
|
<XSso/>
|
||||||
|
|
||||||
<SearchMarker :keywords="['signin', 'login', 'history', 'log']">
|
<SearchMarker :keywords="['signin', 'login', 'history', 'log']">
|
||||||
<FormSection>
|
<FormSection>
|
||||||
<template #label><SearchLabel>{{ i18n.ts.signinHistory }}</SearchLabel></template>
|
<template #label><SearchLabel>{{ i18n.ts.signinHistory }}</SearchLabel></template>
|
||||||
|
|
@ -59,6 +61,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { computed, markRaw } from 'vue';
|
import { computed, markRaw } from 'vue';
|
||||||
import X2fa from './2fa.vue';
|
import X2fa from './2fa.vue';
|
||||||
|
import XSso from './sso.vue';
|
||||||
import FormSection from '@/components/form/section.vue';
|
import FormSection from '@/components/form/section.vue';
|
||||||
import FormSlot from '@/components/form/slot.vue';
|
import FormSlot from '@/components/form/slot.vue';
|
||||||
import MkButton from '@/components/MkButton.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>
|
<template>
|
||||||
<div :class="$style.root">
|
<div :class="$style.root">
|
||||||
<MkLoading v-if="state === 'loading'"/>
|
<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>
|
<i class="ti ti-circle-x" :class="$style.errorIcon"></i>
|
||||||
<div>{{ i18n.ts.signinFailed }}</div>
|
<div>{{ i18n.ts.signinFailed }}</div>
|
||||||
<MkButton primary rounded style="margin: 0 auto;" @click="retry">{{ i18n.ts.retry }}</MkButton>
|
<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>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { onMounted, ref } from 'vue';
|
import { computed, onMounted, ref } from 'vue';
|
||||||
import MkButton from '@/components/MkButton.vue';
|
import MkButton from '@/components/MkButton.vue';
|
||||||
import { i18n } from '@/i18n.js';
|
import { i18n } from '@/i18n.js';
|
||||||
import { login } from '@/accounts.js';
|
import { login } from '@/accounts.js';
|
||||||
|
import { useRouter } from '@/router.js';
|
||||||
import { definePage } from '@/page.js';
|
import { definePage } from '@/page.js';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
session?: string;
|
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> {
|
async function exchange(): Promise<void> {
|
||||||
state.value = 'loading';
|
state.value = 'loading';
|
||||||
|
|
@ -62,7 +84,16 @@ function retry(): void {
|
||||||
window.location.href = '/sso/oidc/login';
|
window.location.href = '/sso/oidc/login';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function backToSettings(): void {
|
||||||
|
router.push('/settings/security');
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
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();
|
exchange();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -81,7 +112,7 @@ definePage(() => ({
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
.error {
|
.message {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -89,4 +120,9 @@ definePage(() => ({
|
||||||
font-size: 2.5em;
|
font-size: 2.5em;
|
||||||
color: var(--MI_THEME-error);
|
color: var(--MI_THEME-error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.successIcon {
|
||||||
|
font-size: 2.5em;
|
||||||
|
color: var(--MI_THEME-success);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -302,6 +302,8 @@ export const ROUTE_DEF = [{
|
||||||
component: page(() => import('@/pages/sso-oidc-redirect.vue')),
|
component: page(() => import('@/pages/sso-oidc-redirect.vue')),
|
||||||
query: {
|
query: {
|
||||||
session: 'session',
|
session: 'session',
|
||||||
|
link: 'link',
|
||||||
|
reason: 'reason',
|
||||||
},
|
},
|
||||||
}, {
|
}, {
|
||||||
path: '/tags/:tag',
|
path: '/tags/:tag',
|
||||||
|
|
|
||||||
|
|
@ -9381,6 +9381,60 @@ export interface Locale extends ILocale {
|
||||||
*/
|
*/
|
||||||
"moreDetailedGuideHere": string;
|
"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": {
|
"_permissions": {
|
||||||
/**
|
/**
|
||||||
* アカウントの情報を見る
|
* アカウントの情報を見る
|
||||||
|
|
|
||||||
|
|
@ -1994,6 +1994,10 @@ declare namespace entities {
|
||||||
IRevokeTokenRequest,
|
IRevokeTokenRequest,
|
||||||
ISigninHistoryRequest,
|
ISigninHistoryRequest,
|
||||||
ISigninHistoryResponse,
|
ISigninHistoryResponse,
|
||||||
|
ISsoOidcGenerateLinkUrlRequest,
|
||||||
|
ISsoOidcGenerateLinkUrlResponse,
|
||||||
|
ISsoOidcListResponse,
|
||||||
|
ISsoOidcUnlinkRequest,
|
||||||
IUnpinRequest,
|
IUnpinRequest,
|
||||||
IUnpinResponse,
|
IUnpinResponse,
|
||||||
IUpdateRequest,
|
IUpdateRequest,
|
||||||
|
|
@ -2774,6 +2778,18 @@ type ISigninHistoryResponse = operations['i___signin-history']['responses']['200
|
||||||
// @public (undocumented)
|
// @public (undocumented)
|
||||||
function isPureRenote(note: Note): note is PureRenote;
|
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)
|
// @public (undocumented)
|
||||||
export interface IStream extends EventEmitter<StreamEvents> {
|
export interface IStream extends EventEmitter<StreamEvents> {
|
||||||
// (undocumented)
|
// (undocumented)
|
||||||
|
|
|
||||||
|
|
@ -3425,6 +3425,42 @@ declare module '../api.js' {
|
||||||
credential?: string | null,
|
credential?: string | null,
|
||||||
): Promise<SwitchCaseResponseType<E, P>>;
|
): 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.
|
* No description provided.
|
||||||
*
|
*
|
||||||
|
|
|
||||||
|
|
@ -463,6 +463,10 @@ import type {
|
||||||
IRevokeTokenRequest,
|
IRevokeTokenRequest,
|
||||||
ISigninHistoryRequest,
|
ISigninHistoryRequest,
|
||||||
ISigninHistoryResponse,
|
ISigninHistoryResponse,
|
||||||
|
ISsoOidcGenerateLinkUrlRequest,
|
||||||
|
ISsoOidcGenerateLinkUrlResponse,
|
||||||
|
ISsoOidcListResponse,
|
||||||
|
ISsoOidcUnlinkRequest,
|
||||||
IUnpinRequest,
|
IUnpinRequest,
|
||||||
IUnpinResponse,
|
IUnpinResponse,
|
||||||
IUpdateRequest,
|
IUpdateRequest,
|
||||||
|
|
@ -974,6 +978,9 @@ export type Endpoints = {
|
||||||
'i/registry/set': { req: IRegistrySetRequest; res: EmptyResponse };
|
'i/registry/set': { req: IRegistrySetRequest; res: EmptyResponse };
|
||||||
'i/revoke-token': { req: IRevokeTokenRequest; res: EmptyResponse };
|
'i/revoke-token': { req: IRevokeTokenRequest; res: EmptyResponse };
|
||||||
'i/signin-history': { req: ISigninHistoryRequest; res: ISigninHistoryResponse };
|
'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/unpin': { req: IUnpinRequest; res: IUnpinResponse };
|
||||||
'i/update': { req: IUpdateRequest; res: IUpdateResponse };
|
'i/update': { req: IUpdateRequest; res: IUpdateResponse };
|
||||||
'i/update-email': { req: IUpdateEmailRequest; res: IUpdateEmailResponse };
|
'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 IRevokeTokenRequest = operations['i___revoke-token']['requestBody']['content']['application/json'];
|
||||||
export type ISigninHistoryRequest = operations['i___signin-history']['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 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 IUnpinRequest = operations['i___unpin']['requestBody']['content']['application/json'];
|
||||||
export type IUnpinResponse = operations['i___unpin']['responses']['200']['content']['application/json'];
|
export type IUnpinResponse = operations['i___unpin']['responses']['200']['content']['application/json'];
|
||||||
export type IUpdateRequest = operations['i___update']['requestBody']['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'];
|
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': {
|
||||||
/**
|
/**
|
||||||
* 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: {
|
i___unpin: {
|
||||||
requestBody: {
|
requestBody: {
|
||||||
content: {
|
content: {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue