Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .mise/config.toml
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ run = [{ task = "*:fix" }, { task = "web:lingui" }]

[env]
# default environment variables for development
NODE_ENV = "development"
YUCCA_API_PORT = 3000
RESTIC_API_PORT = 3010
S3_ACCESS_KEY_ID = "minio"
Expand Down
109 changes: 20 additions & 89 deletions packages/yucca-api-client/openapi-specs.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,34 +39,6 @@
"/api/auth/oidc/login": {
"get": {
"operationId": "oidcAuthorize",
"parameters": [],
"responses": {
"200": {
"description": ""
}
},
"tags": [
"Auth"
]
}
},
"/api/auth/oidc/callback": {
"get": {
"operationId": "oidcCallback",
"parameters": [],
"responses": {
"200": {
"description": ""
}
},
"tags": [
"Auth"
]
}
},
"/api/auth/app/login": {
"get": {
"operationId": "appAuthorize",
"parameters": [
{
"name": "code_challenge",
Expand All @@ -75,6 +47,22 @@
"schema": {
"type": "string"
}
},
{
"name": "redirect_uri",
"required": true,
"in": "query",
"schema": {
"type": "string"
}
},
{
"name": "state",
"required": true,
"in": "query",
"schema": {
"type": "string"
}
}
],
"responses": {
Expand All @@ -87,44 +75,13 @@
]
}
},
"/api/auth/app/callback": {
"post": {
"operationId": "appCallback",
"parameters": [],
"responses": {
"201": {
"description": ""
}
},
"tags": [
"Auth"
]
}
},
"/api/auth/app/token": {
"post": {
"operationId": "appToken",
"/api/auth/oidc/callback": {
"get": {
"operationId": "oidcCallback",
"parameters": [],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/AppTokenRequestDto"
}
}
}
},
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/AppTokenResponseDto"
}
}
}
"description": ""
}
},
"tags": [
Expand Down Expand Up @@ -240,32 +197,6 @@
"sessionId"
]
},
"AppTokenRequestDto": {
"type": "object",
"properties": {
"codeVerifier": {
"type": "string"
},
"code": {
"type": "string"
}
},
"required": [
"codeVerifier",
"code"
]
},
"AppTokenResponseDto": {
"type": "object",
"properties": {
"accessToken": {
"type": "string"
}
},
"required": [
"accessToken"
]
},
"RepositoryMetricsDto": {
"type": "object",
"properties": {
Expand Down
38 changes: 6 additions & 32 deletions packages/yucca-api-client/src/fetch-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,6 @@ export type AuthDto = {
email: string;
sessionId: string;
};
export type AppTokenRequestDto = {
codeVerifier: string;
code: string;
};
export type AppTokenResponseDto = {
accessToken: string;
};
export type RepositoryMetricsDto = {
lastUpload?: string;
sizeBytes: number;
Expand Down Expand Up @@ -58,8 +51,12 @@ export function logout(opts?: Oazapfts.RequestOpts) {
...opts
}));
}
export function oidcAuthorize(opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchText("/api/auth/oidc/login", {
export function oidcAuthorize(codeChallenge: string, redirectUri: string, state: string, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchText(`/api/auth/oidc/login${QS.query(QS.explode({
code_challenge: codeChallenge,
redirect_uri: redirectUri,
state
}))}`, {
...opts
}));
}
Expand All @@ -68,29 +65,6 @@ export function oidcCallback(opts?: Oazapfts.RequestOpts) {
...opts
}));
}
export function appAuthorize(codeChallenge: string, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchText(`/api/auth/app/login${QS.query(QS.explode({
code_challenge: codeChallenge
}))}`, {
...opts
}));
}
export function appCallback(opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchText("/api/auth/app/callback", {
...opts,
method: "POST"
}));
}
export function appToken(appTokenRequestDto: AppTokenRequestDto, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 200;
data: AppTokenResponseDto;
}>("/api/auth/app/token", oazapfts.json({
...opts,
method: "POST",
body: appTokenRequestDto
})));
}
export function createRepository(opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 200;
Expand Down
75 changes: 21 additions & 54 deletions packages/yucca-api/src/controllers/auth.controller.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { Body, Controller, Get, Post, Query, Req, Res } from '@nestjs/common';
import { ApiOkResponse } from '@nestjs/swagger';
import { Controller, Get, Query, Req, Res } from '@nestjs/common';
import { ApiOkResponse, ApiQuery } from '@nestjs/swagger';
import { type Request, type Response } from 'express';
import { Duration } from 'luxon';
import { AppTokenRequestDto, AppTokenResponseDto, AuthDto } from 'src/dto/auth.dto';
import { CookieName, OidcLoginFlow } from 'src/enum';
import { Auth, AuthRoute, OptionalAuth } from 'src/middleware/auth.guard';
import { AuthDto } from 'src/dto/auth.dto';
import { CookieName } from 'src/enum';
import { Auth, AuthRoute } from 'src/middleware/auth.guard';
import { AuthService } from 'src/services/auth.service';

@Controller('/auth')
Expand All @@ -27,10 +27,22 @@ export class AuthController {
}

@Get('/oidc/login')
async oidcAuthorize(@Res({ passthrough: true }) response: Response) {
const { redirectTo, state, codeVerifier } = await this.auth.oidcAuthorize();
@ApiQuery({ name: 'code_challenge', type: String })
@ApiQuery({ name: 'redirect_uri', type: String })
@ApiQuery({ name: 'state', type: String })
async oidcAuthorize(
@Query('code_challenge') codeChallenge: string,
@Query('redirect_uri') redirectUri: string,
@Query('state') state: string,
@Res({ passthrough: true }) response: Response,
) {
const {
redirectTo,
state: newState,
codeVerifier,
} = await this.auth.oidcAuthorize(codeChallenge, redirectUri, state);

response.cookie(CookieName.OidcState, state);
response.cookie(CookieName.OidcState, newState);
response.cookie(CookieName.OidcCodeVerifier, codeVerifier);

response.redirect(redirectTo);
Expand All @@ -42,7 +54,7 @@ export class AuthController {

response.clearCookie(CookieName.OidcState);
response.clearCookie(CookieName.OidcCodeVerifier);
response.clearCookie(CookieName.OidcLoginFlow);

response.cookie(CookieName.AccessToken, accessToken, {
path: '/',
sameSite: 'lax',
Expand All @@ -53,49 +65,4 @@ export class AuthController {

response.redirect(redirectTo);
}

@AuthRoute()
@OptionalAuth()
@Get('/app/login')
appAuthorize(
@Req() request: Request,
@Auth() auth: AuthDto | undefined,
@Query('code_challenge') challenge: string,
@Res({ passthrough: true }) response: Response,
) {
response.cookie(CookieName.AppCodeChallenge, challenge, {
path: '/',
sameSite: 'lax',
httpOnly: true,
secure: request.protocol === 'https',
maxAge: Duration.fromObject({ minutes: 15 }).toMillis(),
});

const { redirectTo, setFlowCookie } = this.auth.appAuthorize(auth);

if (setFlowCookie) {
response.cookie(CookieName.OidcLoginFlow, OidcLoginFlow.App, {
path: '/',
sameSite: 'lax',
httpOnly: true,
secure: request.protocol === 'https',
maxAge: Duration.fromObject({ minutes: 15 }).toMillis(),
});
}

response.redirect(redirectTo);
}

@AuthRoute()
@Post('/app/callback')
async appCallback(@Req() request: Request, @Auth() auth: AuthDto, @Res({ passthrough: true }) response: Response) {
const { redirectTo } = await this.auth.appCallback(auth, request.headers);
response.redirect(redirectTo);
}

@Post('/app/token')
@ApiOkResponse({ type: AppTokenResponseDto })
appToken(@Body() dto: AppTokenRequestDto): Promise<AppTokenResponseDto> {
return this.auth.appToken(dto);
}
}
12 changes: 0 additions & 12 deletions packages/yucca-api/src/dto/auth.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,3 @@ export class AuthDto {
@ApiProperty()
sessionId!: string;
}

export class AppTokenRequestDto {
@ApiProperty()
codeVerifier!: string;
@ApiProperty()
code!: string;
}

export class AppTokenResponseDto {
@ApiProperty()
accessToken!: string;
}
14 changes: 4 additions & 10 deletions packages/yucca-api/src/enum.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,11 @@
export enum CookieName {
AccessToken = 'access-token',
OidcState = 'oidc-state',
OidcCodeVerifier = 'oidc-code-verifier',
OidcLoginFlow = 'oidc-login-flow',
AppCodeChallenge = 'app-code-challenge',
AccessToken = 'yucca-access-token',
OidcState = 'yucca-oidc-state',
OidcCodeVerifier = 'yucca-oidc-code-verifier',
AppCodeChallenge = 'yucca-app-code-challenge',
}

export enum MetadataKey {
Auth = 'AUTH',
OptionalAuth = 'OPTIONAL_AUTH',
}

export enum OidcLoginFlow {
Default = 'default',
App = 'app',
}
23 changes: 17 additions & 6 deletions packages/yucca-api/src/repositories/oidc.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,27 +7,34 @@ export class OidcRepository implements OnModuleInit {

async onModuleInit() {
this.config = await client.discovery(env.OIDC_ISSUER, env.OIDC_CLIENT_ID, env.OIDC_CLIENT_SECRET, undefined, {
execute: [client.allowInsecureRequests],
execute: env.NODE_ENV === 'development' ? [client.allowInsecureRequests] : [],
});

if (env.OIDC_REQUIRE_PKCE && !this.config.serverMetadata().supportsPKCE()) {
throw new Error('OIDC server does not support PKCE while OIDC_REQUIRE_PKCE is true!');
}
}

async authorize(): Promise<{ redirectTo: URL; state: string; codeVerifier: string }> {
const codeVerifier = client.randomPKCECodeVerifier();
const codeChallenge = await client.calculatePKCECodeChallenge(codeVerifier);
async authorize(
codeChallenge?: string,
redirectUri?: string,
state?: string,
): Promise<{ redirectTo: URL; state: string; codeVerifier?: string }> {
let codeVerifier;
if (!codeChallenge) {
codeVerifier = client.randomPKCECodeVerifier();
codeChallenge = await client.calculatePKCECodeChallenge(codeVerifier);
}

const parameters: Record<string, string> = {
redirect_uri: env.OIDC_REDIRECT_URI,
redirect_uri: redirectUri ?? env.OIDC_REDIRECT_URI,
scope: env.OIDC_SCOPE,
code_challenge: codeChallenge,
code_challenge_method: 'S256',
};

// non-PKCE fallback
const state = client.randomState();
state ??= client.randomState();
parameters.state = state;

const redirectTo: URL = client.buildAuthorizationUrl(this.config, parameters);
Expand All @@ -44,6 +51,10 @@ export class OidcRepository implements OnModuleInit {
return tokens.claims();
}

async fetchUserInfo(accessToken: string, sub: string): Promise<client.UserInfoResponse | undefined> {
return await client.fetchUserInfo(this.config, accessToken, sub);
}

logout(): URL | void {
const endpoint = this.config.serverMetadata().end_session_endpoint;

Expand Down
Loading
Loading