generated from bastian/boilerplate
mvp
This commit is contained in:
@@ -17,8 +17,8 @@ export class AuthController {
|
||||
@Public()
|
||||
@SensitiveRateLimit()
|
||||
@Redirect()
|
||||
async login() {
|
||||
return { url: await this.auth.createLoginUrl() };
|
||||
async login(@Query('returnTo') returnTo?: string) {
|
||||
return { url: await this.auth.createLoginUrl(returnTo) };
|
||||
}
|
||||
|
||||
@Get('callback')
|
||||
@@ -30,7 +30,7 @@ export class AuthController {
|
||||
@Req() req: AuthenticatedRequest,
|
||||
@Res() res: Response,
|
||||
) {
|
||||
const { session, csrfToken } = await this.auth.completeLogin(
|
||||
const { session, csrfToken, returnPath } = await this.auth.completeLogin(
|
||||
code,
|
||||
state,
|
||||
req.get('user-agent'),
|
||||
@@ -51,7 +51,9 @@ export class AuthController {
|
||||
path: '/',
|
||||
expires: session.absoluteExpiresAt,
|
||||
});
|
||||
res.redirect(this.config.frontendBaseUrl);
|
||||
res.redirect(
|
||||
new URL(returnPath ?? '/', this.config.frontendBaseUrl).toString(),
|
||||
);
|
||||
}
|
||||
|
||||
@Get('logout')
|
||||
|
||||
@@ -9,6 +9,50 @@ import type { OidcLoginStateEntity } from './entities/oidc-login-state.entity';
|
||||
import { AuthService } from './auth.service';
|
||||
|
||||
describe('AuthService', () => {
|
||||
it('stores only an internal return path in the short-lived OIDC login state', async () => {
|
||||
const saved: OidcLoginStateEntity[] = [];
|
||||
const service = new AuthService(
|
||||
{
|
||||
frontendBaseUrl: 'https://app.example.test',
|
||||
appBaseUrl: 'https://app.example.test',
|
||||
oidc: {
|
||||
issuer: 'https://idp.example.test',
|
||||
clientId: 'business-app',
|
||||
clientSecret: 'secret',
|
||||
scopes: 'openid profile email',
|
||||
allowedAlgorithms: ['RS256'],
|
||||
httpTimeoutMs: 5000,
|
||||
},
|
||||
} as AppConfigService,
|
||||
{
|
||||
requestJson: () =>
|
||||
Promise.resolve({
|
||||
issuer: 'https://idp.example.test',
|
||||
authorization_endpoint: 'https://idp.example.test/authorize',
|
||||
token_endpoint: 'https://idp.example.test/token',
|
||||
jwks_uri: 'https://idp.example.test/jwks',
|
||||
}),
|
||||
} as unknown as ExternalHttpClient,
|
||||
{} as RolesService,
|
||||
{} as UsersRepository,
|
||||
{} as SessionsService,
|
||||
{} as DataSource,
|
||||
{
|
||||
delete: () => Promise.resolve({}),
|
||||
save: (state: OidcLoginStateEntity) => {
|
||||
saved.push(state);
|
||||
return Promise.resolve(state);
|
||||
},
|
||||
} as unknown as Repository<OidcLoginStateEntity>,
|
||||
);
|
||||
|
||||
await service.createLoginUrl('/einladungen/sicher');
|
||||
await service.createLoginUrl('//evil.example/path');
|
||||
|
||||
expect(saved[0]?.returnPath).toBe('/einladungen/sicher');
|
||||
expect(saved[1]?.returnPath).toBeNull();
|
||||
});
|
||||
|
||||
it('revokes the local session and redirects to the OIDC logout endpoint', async () => {
|
||||
const revoke = vi.fn<() => Promise<number>>(() => Promise.resolve(1));
|
||||
const getIdTokenForLogout = vi.fn<() => Promise<string | undefined>>(() =>
|
||||
|
||||
@@ -31,7 +31,7 @@ export class AuthService {
|
||||
private readonly loginStates: Repository<OidcLoginStateEntity>,
|
||||
) {}
|
||||
|
||||
async createLoginUrl(): Promise<string> {
|
||||
async createLoginUrl(returnTo?: string): Promise<string> {
|
||||
const discovery = await this.discovery();
|
||||
const state = randomBytes(32).toString('hex');
|
||||
const nonce = randomBytes(32).toString('base64url');
|
||||
@@ -45,6 +45,7 @@ export class AuthService {
|
||||
loginState.state = state;
|
||||
loginState.codeVerifier = codeVerifier;
|
||||
loginState.nonce = nonce;
|
||||
loginState.returnPath = this.safeReturnPath(returnTo);
|
||||
loginState.expiresAt = new Date(Date.now() + 10 * 60 * 1000);
|
||||
await this.loginStates.save(loginState);
|
||||
|
||||
@@ -96,7 +97,7 @@ export class AuthService {
|
||||
);
|
||||
}
|
||||
|
||||
return this.sessions.createSession(
|
||||
const result = await this.sessions.createSession(
|
||||
user,
|
||||
{
|
||||
accessToken: tokens.access_token,
|
||||
@@ -109,6 +110,7 @@ export class AuthService {
|
||||
userAgent,
|
||||
ip,
|
||||
);
|
||||
return { ...result, returnPath: loginState.returnPath };
|
||||
}
|
||||
|
||||
async logout(sessionId: string | undefined): Promise<string> {
|
||||
@@ -146,6 +148,10 @@ export class AuthService {
|
||||
}
|
||||
user.name = profile.name ?? profile.email ?? profile.sub;
|
||||
user.email = profile.email ?? null;
|
||||
user.emailVerified =
|
||||
typeof profile.email_verified === 'boolean'
|
||||
? profile.email_verified
|
||||
: null;
|
||||
user.lastLoginAt = new Date();
|
||||
const savedUser = await manager.getRepository(UserEntity).save(user);
|
||||
savedUser.settings = await this.ensureUserSettings(manager, savedUser);
|
||||
@@ -296,6 +302,9 @@ export class AuthService {
|
||||
if (typeof payload['email'] === 'string') {
|
||||
fallback.email = payload['email'];
|
||||
}
|
||||
if (typeof payload['email_verified'] === 'boolean') {
|
||||
fallback.email_verified = payload['email_verified'];
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
const userInfo = await this.http.requestJson<OidcUserInfo>(
|
||||
@@ -311,6 +320,21 @@ export class AuthService {
|
||||
401,
|
||||
);
|
||||
}
|
||||
if (!userInfo.name && typeof payload['name'] === 'string') {
|
||||
userInfo.name = payload['name'];
|
||||
}
|
||||
if (!userInfo.email && typeof payload['email'] === 'string') {
|
||||
userInfo.email = payload['email'];
|
||||
}
|
||||
if (
|
||||
typeof userInfo.email_verified !== 'boolean' &&
|
||||
typeof payload['email_verified'] === 'boolean' &&
|
||||
typeof payload['email'] === 'string' &&
|
||||
payload['email'].trim().toLowerCase() ===
|
||||
userInfo.email?.trim().toLowerCase()
|
||||
) {
|
||||
userInfo.email_verified = payload['email_verified'];
|
||||
}
|
||||
return userInfo;
|
||||
}
|
||||
|
||||
@@ -325,4 +349,18 @@ export class AuthService {
|
||||
private get callbackUrl(): string {
|
||||
return new URL('/api/auth/callback', this.config.appBaseUrl).toString();
|
||||
}
|
||||
|
||||
private safeReturnPath(value: string | undefined): string | null {
|
||||
if (
|
||||
!value ||
|
||||
value.length > 500 ||
|
||||
!value.startsWith('/') ||
|
||||
value.startsWith('//') ||
|
||||
value.includes('\\') ||
|
||||
/^[a-z][a-z0-9+.-]*:/i.test(value)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,9 @@ export class OidcLoginStateEntity {
|
||||
@Column({ type: 'varchar', length: 160 })
|
||||
nonce!: string;
|
||||
|
||||
@Column({ name: 'return_path', type: 'varchar', length: 500, nullable: true })
|
||||
returnPath!: string | null;
|
||||
|
||||
@Column({ name: 'expires_at', type: 'datetime', precision: 3 })
|
||||
expiresAt!: Date;
|
||||
|
||||
|
||||
@@ -20,4 +20,5 @@ export interface OidcUserInfo {
|
||||
sub: string;
|
||||
name?: string;
|
||||
email?: string;
|
||||
email_verified?: boolean;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user