feat(center): OIDC login (auto-provision by email) + CLI config-oidc
- OidcConfig entity (single row) + DB registration.
- OidcService: discovery, Authorization Code + PKCE, state/ticket as
short-lived signed JWTs (no server session), userinfo/id_token claim
resolution. Auto-provisions a passwordless user by email.
- Public endpoints /auth/oidc/{status,start,callback,exchange}; callback
bounces a one-time ticket to the SPA in the URL fragment.
- AuthService.provisionOidcUser + issueSessionForUserId (login shape).
- CLI: 'config oidc' upserts issuer/clientId/secret/callback/redirect/
scopes/enabled (secret masked on print).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -116,6 +116,61 @@ export class AuthService {
|
||||
};
|
||||
}
|
||||
|
||||
// OIDC: find a user by email or auto-provision one (no usable password —
|
||||
// OIDC users never password-login). Returns the userId.
|
||||
async provisionOidcUser(email: string, name?: string): Promise<string> {
|
||||
const e = String(email ?? '').trim();
|
||||
if (!e) throw new UnauthorizedException('oidc: missing email claim');
|
||||
let user = await this.userRepo.findOne({ where: { email: e } });
|
||||
if (!user) {
|
||||
const passwordHash = await bcrypt.hash(`oidc!${randomBytes(24).toString('hex')}`, 10);
|
||||
user = await this.userRepo.save(
|
||||
this.userRepo.create({
|
||||
email: e,
|
||||
name: (name ?? '').trim() || e,
|
||||
passwordHash,
|
||||
refreshTokenHash: null,
|
||||
}),
|
||||
);
|
||||
await this.audit.write({
|
||||
action: 'auth.oidc.provision',
|
||||
actorId: user.id,
|
||||
targetType: 'user',
|
||||
targetId: user.id,
|
||||
detail: JSON.stringify({ email: e }),
|
||||
});
|
||||
}
|
||||
return user.id;
|
||||
}
|
||||
|
||||
// Issue a full session for an already-authenticated userId (same shape as
|
||||
// login()). Used by the OIDC ticket exchange.
|
||||
async issueSessionForUserId(userId: string) {
|
||||
const user = await this.userRepo.findOne({ where: { id: userId } });
|
||||
if (!user) throw new UnauthorizedException('user not found');
|
||||
const accessToken = signAccessToken(user.id, user.email);
|
||||
const refreshToken = signRefreshToken(user.id, user.email);
|
||||
user.refreshTokenHash = await bcrypt.hash(refreshToken, 10);
|
||||
await this.userRepo.save(user);
|
||||
await this.audit.write({
|
||||
action: 'auth.oidc.login',
|
||||
actorId: user.id,
|
||||
targetType: 'user',
|
||||
targetId: user.id,
|
||||
detail: null,
|
||||
});
|
||||
const { guilds, guildAccessTokens } = await this.getUserGuildsAndTokens(user.id, user.email);
|
||||
return {
|
||||
accessToken,
|
||||
refreshToken,
|
||||
tokenType: 'Bearer',
|
||||
expiresIn: getAccessTokenExpiresInSeconds(),
|
||||
user: { id: user.id, email: user.email, name: user.name ?? user.email },
|
||||
guilds,
|
||||
guildAccessTokens,
|
||||
};
|
||||
}
|
||||
|
||||
private async getUserGuildsAndTokens(userId: string, email: string) {
|
||||
const memberships = await this.guildUserRepo.find({
|
||||
where: { userId, status: 'active' },
|
||||
|
||||
Reference in New Issue
Block a user