feat(admin): make admin go brr (#327)

This commit is contained in:
Antoine Lecompte
2026-07-24 14:47:10 +00:00
committed by GitHub
parent 89b591c63f
commit 37ecd7bf58
26 changed files with 944 additions and 378 deletions
+9
View File
@@ -8,6 +8,15 @@ export POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-postgres}
export POSTGRES_DATABASE=${POSTGRES_DATABASE:-yucca}
export POSTGRES_PORT=${POSTGRES_PORT:-15432}
# The project's well-known local-dev ES256 keypair (same fixture as
# .mise/tasks/yucca-api/env); signs CLI session JWTs in dev.
export JWT_PRIVATE_KEY=${JWT_PRIVATE_KEY:------BEGIN PRIVATE KEY-----
MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgCla79+Sip4o2hZ1K
nQJYp2xU/nsCJmRoZmvXjeX6cW+hRANCAASkubBRIE7Sns/VS8ZF5XEVO8CwJ+AZ
Ls1C0ncCcmRhqKA7UxLknn0ji5FcKaku1zBOhxQYcxFVmsYtAxZ1ljgN
-----END PRIVATE KEY-----
}
export OIDC_ADMIN_ISSUER=${OIDC_ISSUER:-http://localhost:8092}
export OIDC_ADMIN_CLIENT_ID=${OIDC_CLIENT_ID:-client ID}
export OIDC_ADMIN_CLIENT_SECRET=${OIDC_CLIENT_SECRET:-client secret}
+1 -1
View File
@@ -261,7 +261,7 @@ APP_WIRING = {
# dev_keypair: render the well-known dev JWT fixture into the chart Secret
# (the chart default is useDevKeypair=false so real overlays fail loudly).
'yucca-api': {'build': 'yucca-api', 'deps': ['yucca-database', 'yucca-mock-oidc', 'yucca-michael'], 'dev_env': True, 'dev_keypair': True},
'yucca-admin-api': {'build': 'yucca-admin-api', 'deps': ['yucca-database', 'yucca-mock-oidc']},
'yucca-admin-api': {'build': 'yucca-admin-api', 'deps': ['yucca-database', 'yucca-mock-oidc'], 'dev_keypair': True},
'yucca-metrics-worker': {'build': 'yucca-metrics-worker', 'deps': ['yucca-database', 'yucca-metrics-object-user'], 'dev_env': True},
'yucca-web': {'build': 'web', 'deps': ['yucca-api']},
'yucca-michael': {'build': 'michael', 'deps': ['yucca-object-user'], 'dev_keypair': True},
@@ -1 +1,6 @@
{{- /* The dev JWT fixture only enters the Secret when explicitly opted in
(useDevKeypair — dev/local overlay only); see values.yaml. */}}
{{- if .Values.useDevKeypair }}
{{- $_ := set .Values "secretData" (merge (dict "JWT_PRIVATE_KEY" .Values.devJwtPrivateKey) (.Values.secretData | default dict)) }}
{{- end }}
{{- include "yucca-common.secret" . }}
+14
View File
@@ -33,6 +33,20 @@ secretData:
OIDC_ADMIN_CLIENT_ID: "client ID"
OIDC_ADMIN_CLIENT_SECRET: "client secret"
# OPT-IN dev signing key for CLI session JWTs. The project's well-known
# local-dev ES256 keypair (the same one committed in .mise/tasks/*/env and the
# yucca-api chart) renders into the Secret ONLY when useDevKeypair is true —
# set by the dev/local overlay. Default false: an overlay that forgets to
# provide a real key gets a loud missing-JWT_PRIVATE_KEY crash instead of
# silently signing admin tokens with a public fixture.
useDevKeypair: false
devJwtPrivateKey: |
-----BEGIN PRIVATE KEY-----
MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgCla79+Sip4o2hZ1K
nQJYp2xU/nsCJmRoZmvXjeX6cW+hRANCAASkubBRIE7Sns/VS8ZF5XEVO8CwJ+AZ
Ls1C0ncCcmRhqKA7UxLknn0ji5FcKaku1zBOhxQYcxFVmsYtAxZ1ljgN
-----END PRIVATE KEY-----
env:
- name: NODE_ENV
value: development
@@ -29,6 +29,9 @@ spec:
readOnlyRootFilesystem: false
capabilities:
drop: [ALL]
# Dev-only: render the well-known local-dev JWT signing key into the chart
# Secret (the chart default is false so real overlays fail loudly instead).
useDevKeypair: true
image:
repository: ghcr.io/immich-app/yucca/yucca-admin-api # TODO: confirm prod registry
tag: 0.0.1
+108
View File
@@ -67,6 +67,76 @@
]
}
},
"/api/auth/cli/login": {
"get": {
"operationId": "cliLogin",
"parameters": [
{
"name": "port",
"required": true,
"in": "query",
"schema": {
"type": "number"
}
},
{
"name": "state",
"required": true,
"in": "query",
"schema": {
"type": "string"
}
},
{
"name": "code_challenge",
"required": true,
"in": "query",
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": ""
}
},
"tags": [
"Auth"
]
}
},
"/api/auth/cli/token": {
"post": {
"operationId": "cliToken",
"parameters": [],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CliTokenRequestDto"
}
}
}
},
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CliTokenResponseDto"
}
}
}
}
},
"tags": [
"Auth"
]
}
},
"/api/auth/oidc/callback": {
"get": {
"operationId": "oidcCallback",
@@ -445,6 +515,44 @@
"sub"
]
},
"CliTokenRequestDto": {
"type": "object",
"properties": {
"code": {
"type": "string",
"description": "One-time code delivered to the loopback redirect"
},
"codeVerifier": {
"type": "string",
"description": "Plaintext verifier whose S256 hash was sent as code_challenge on /auth/cli/login"
}
},
"required": [
"code",
"codeVerifier"
]
},
"CliTokenResponseDto": {
"type": "object",
"properties": {
"accessToken": {
"type": "string",
"description": "ES256 session JWT for Authorization: Bearer"
},
"expiresAt": {
"type": "string",
"description": "Session expiry, ISO 8601"
},
"sub": {
"type": "string"
}
},
"required": [
"accessToken",
"expiresAt",
"sub"
]
},
"UserDto": {
"type": "object",
"properties": {
+15 -1
View File
@@ -1,11 +1,14 @@
import { LoggerRepository, LoggingInterceptor, OtelModule, WideContextRepository } from '@common/server/otel';
import { Module } from '@nestjs/common';
import { APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core';
import { JwtModule } from '@nestjs/jwt';
import { KyselyModule } from 'nestjs-kysely';
import { createPublicKey } from 'node:crypto';
import { AuthController } from './controllers/auth.controller';
import { RepositoryController } from './controllers/repository.controller';
import { SessionController } from './controllers/session.controller';
import { UserController } from './controllers/user.controller';
import { env } from './env';
import { AuthGuard } from './middleware/auth.guard';
import { DatabaseRepository } from './repositories/database.repository';
import { OidcRepository } from './repositories/oidc.repository';
@@ -19,7 +22,18 @@ import { SessionService } from './services/session.service';
import { UserService } from './services/user.service';
import { getKyselyConfig } from './utils/database';
export const imports = [KyselyModule.forRoot(getKyselyConfig())];
export const imports = [
JwtModule.register({
global: true,
privateKey: env.JWT_PRIVATE_KEY,
// Verification key derived from the signing key: CLI session JWTs are
// minted and validated by this same service.
publicKey: createPublicKey(env.JWT_PRIVATE_KEY).export({ type: 'spki', format: 'pem' }).toString(),
signOptions: { algorithm: 'ES256', expiresIn: env.JWT_EXPIRES_IN },
verifyOptions: { algorithms: ['ES256'] },
}),
KyselyModule.forRoot(getKyselyConfig()),
];
export const controllers = [AuthController, UserController, SessionController, RepositoryController];
@@ -1,11 +1,12 @@
import { Controller, Get, Query, Req, Res } from '@nestjs/common';
import { Body, Controller, Get, Post, Query, Req, Res } from '@nestjs/common';
import { ApiOkResponse, ApiQuery } from '@nestjs/swagger';
import { parse } from 'cookie';
import { type Request, type Response } from 'express';
import { Duration } from 'luxon';
import { AuthDto } from 'src/dto/auth.dto';
import { AuthDto, CliTokenRequestDto, CliTokenResponseDto } 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';
import { AuthService, type CliLoginParams } from 'src/services/auth.service';
@Controller('/auth')
export class AuthController {
@@ -43,6 +44,40 @@ export class AuthController {
response.redirect(redirectTo);
}
// Loopback login for yuctl: the CLI opens this in a browser with its
// listener port, a state nonce, and an S256 code challenge; the normal OIDC
// dance runs, and the callback redirects to 127.0.0.1:<port> with a one-time
// code the CLI exchanges at /auth/cli/token. The CLI params ride along in
// their own short-lived cookie, mirroring how state/verifier already travel.
@Get('/cli/login')
@ApiQuery({ name: 'port', type: Number })
@ApiQuery({ name: 'state', type: String })
@ApiQuery({ name: 'code_challenge', type: String })
async cliLogin(
@Query('port') port: string,
@Query('state') state: string,
@Query('code_challenge') codeChallenge: string,
@Res({ passthrough: true }) response: Response,
) {
const params = this.auth.parseCliLoginParams(port, state, codeChallenge);
const { redirectTo, state: oidcState, codeVerifier } = await this.auth.oidcAuthorize();
response.cookie(CookieName.CliLogin, JSON.stringify(params), {
maxAge: Duration.fromObject({ minutes: 10 }).toMillis(),
});
response.cookie(CookieName.OidcState, oidcState);
response.cookie(CookieName.OidcCodeVerifier, codeVerifier);
response.redirect(redirectTo);
}
@Post('/cli/token')
@ApiOkResponse({ type: CliTokenResponseDto })
async cliToken(@Body() body: CliTokenRequestDto): Promise<CliTokenResponseDto> {
return await this.auth.cliToken(body.code, body.codeVerifier);
}
@Get('/oidc/callback')
async oidcCallback(@Req() request: Request, @Res() response: Response) {
const { sub, accessToken, redirectTo } = await this.auth.oidcCallback(request);
@@ -66,6 +101,25 @@ export class AuthController {
maxAge: Duration.fromObject({ days: 7 }).toMillis(),
});
// CLI loopback flow: hand the browser back to the local yuctl listener
// with a one-time code instead of the admin UI.
const cliCookie = parse(request.headers.cookie ?? '')[CookieName.CliLogin];
if (cliCookie) {
// Re-validate: the cookie is client-controlled, and the values are
// interpolated into the loopback redirect.
const raw = JSON.parse(cliCookie) as CliLoginParams;
const { port, state, codeChallenge } = this.auth.parseCliLoginParams(
String(raw.port),
raw.state,
raw.codeChallenge,
);
const code = await this.auth.mintCliCode(sub, codeChallenge);
response.clearCookie(CookieName.CliLogin);
response.redirect(`http://127.0.0.1:${port}/callback?code=${encodeURIComponent(code)}&state=${state}`);
return;
}
response.redirect(redirectTo);
}
}
@@ -1,6 +1,30 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsString } from 'class-validator';
export class AuthDto {
@ApiProperty()
sub!: string;
}
export class CliTokenRequestDto {
@ApiProperty({ description: 'One-time code delivered to the loopback redirect' })
@IsString()
@IsNotEmpty()
code!: string;
@ApiProperty({ description: 'Plaintext verifier whose S256 hash was sent as code_challenge on /auth/cli/login' })
@IsString()
@IsNotEmpty()
codeVerifier!: string;
}
export class CliTokenResponseDto {
@ApiProperty({ description: 'ES256 session JWT for Authorization: Bearer' })
accessToken!: string;
@ApiProperty({ description: 'Session expiry, ISO 8601' })
expiresAt!: string;
@ApiProperty()
sub!: string;
}
+9
View File
@@ -3,6 +3,15 @@ export enum CookieName {
AccessToken = 'yucca-admin-access-token',
OidcState = 'yucca-admin-oidc-state',
OidcCodeVerifier = 'yucca-admin-oidc-code-verifier',
CliLogin = 'yucca-admin-cli-login',
}
// Audiences of the ES256 JWTs this service mints for the CLI login flow.
export enum JwtAudience {
// One-time authorization code handed to the loopback redirect (short TTL).
CliCode = 'yucca-admin-cli-code',
// CLI session token sent as `Authorization: Bearer`.
Cli = 'yucca-admin-cli',
}
export enum MetadataKey {
+8
View File
@@ -1,3 +1,4 @@
import type { StringValue } from 'ms';
import { z } from 'zod';
const schema = z.object({
@@ -12,6 +13,13 @@ const schema = z.object({
POSTGRES_DATABASE: z.string(),
POSTGRES_SSL: z.union([z.enum(['require', 'allow', 'prefer', 'verify-full']), z.boolean()]).default(false),
JWT_PRIVATE_KEY: z.string(),
JWT_EXPIRES_IN: z
.string()
.regex(/^\d+\s*(ms|s|m|h|d|w|y)$/i, 'Expected a duration like "1d", "30m", "3600s"')
.default('24h')
.transform((value): StringValue => value as StringValue),
OIDC_ADMIN_ISSUER: z.url().transform((url) => new URL(url)),
OIDC_ADMIN_CLIENT_ID: z.string(),
OIDC_ADMIN_CLIENT_SECRET: z.string(),
@@ -1,13 +1,26 @@
import { JwtService } from '@nestjs/jwt';
import { createHash, createPublicKey } from 'node:crypto';
import { env } from 'src/env';
import { Mocks, newMocks } from '../../test/mocks';
import { AuthService } from './auth.service';
// Real JwtService over the local-dev keypair: the CLI-flow tests exercise
// actual ES256 mint/verify rather than mocked crypto.
const newJwtService = () =>
new JwtService({
privateKey: env.JWT_PRIVATE_KEY,
publicKey: createPublicKey(env.JWT_PRIVATE_KEY).export({ type: 'spki', format: 'pem' }).toString(),
signOptions: { algorithm: 'ES256', expiresIn: env.JWT_EXPIRES_IN },
verifyOptions: { algorithms: ['ES256'] },
});
describe(AuthService.name, () => {
let mocks: Mocks;
let sut: AuthService;
beforeEach(() => {
mocks = newMocks();
sut = new AuthService(mocks.oidc as never, mocks.wideContext);
sut = new AuthService(mocks.oidc as never, newJwtService(), mocks.wideContext);
});
it('should exist', () => {
@@ -141,4 +154,55 @@ describe(AuthService.name, () => {
expect(mocks.wideContext.assignContext).toHaveBeenCalledWith({ claims });
});
});
describe('cli login flow', () => {
const verifier = 'cli-verifier-0123456789abcdef';
const challenge = createHash('sha256').update(verifier).digest('base64url');
it('should authenticate a Bearer token minted via cliToken', async () => {
const code = await sut.mintCliCode('oidc-sub', challenge);
const { accessToken, sub, expiresAt } = await sut.cliToken(code, verifier);
expect(sub).toBe('oidc-sub');
expect(new Date(expiresAt).getTime()).toBeGreaterThan(Date.now());
await expect(sut.authenticate({ authorization: `Bearer ${accessToken}` })).resolves.toEqual({ sub: 'oidc-sub' });
});
it('should reject a mismatched code verifier', async () => {
const code = await sut.mintCliCode('oidc-sub', challenge);
await expect(sut.cliToken(code, 'some-other-verifier')).rejects.toThrowErrorMatchingInlineSnapshot(
`"code_verifier does not match code_challenge"`,
);
});
it('should reject a session token used as a code and vice versa', async () => {
const code = await sut.mintCliCode('oidc-sub', challenge);
const { accessToken } = await sut.cliToken(code, verifier);
await expect(sut.cliToken(accessToken, verifier)).rejects.toThrowErrorMatchingInlineSnapshot(
`"Invalid or expired CLI login code"`,
);
await expect(sut.authenticate({ authorization: `Bearer ${code}` })).rejects.toThrowErrorMatchingInlineSnapshot(
`"Invalid CLI session token"`,
);
});
it('should reject garbage Bearer tokens', async () => {
await expect(sut.authenticate({ authorization: 'Bearer not-a-jwt' })).rejects.toThrowErrorMatchingInlineSnapshot(
`"Invalid CLI session token"`,
);
});
it('should validate cli login params', () => {
expect(() => sut.parseCliLoginParams('80', 'a'.repeat(32), challenge)).toThrow('port');
expect(() => sut.parseCliLoginParams('8000', 'bad state!', challenge)).toThrow('state');
expect(() => sut.parseCliLoginParams('8000', 'a'.repeat(32), 'nope~')).toThrow('code_challenge');
expect(sut.parseCliLoginParams('8000', 'a'.repeat(32), challenge)).toEqual({
port: 8000,
state: 'a'.repeat(32),
codeChallenge: challenge,
});
});
});
});
@@ -1,21 +1,41 @@
import { WideContextRepository } from '@common/server/otel';
import { Injectable, InternalServerErrorException, UnauthorizedException } from '@nestjs/common';
import { BadRequestException, Injectable, InternalServerErrorException, UnauthorizedException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { parse } from 'cookie';
import { Request } from 'express';
import { createHash } from 'node:crypto';
import { IncomingHttpHeaders } from 'node:http';
import { AuthDto } from 'src/dto/auth.dto';
import { CookieName } from 'src/enum';
import { AuthDto, CliTokenResponseDto } from 'src/dto/auth.dto';
import { CookieName, JwtAudience } from 'src/enum';
import { env } from 'src/env';
import { OidcRepository } from 'src/repositories/oidc.repository';
// Loopback-flow parameters set by the CLI on /auth/cli/login, carried through
// the OIDC dance in the CliLogin cookie.
export interface CliLoginParams {
port: number;
state: string;
codeChallenge: string;
}
// state / code_challenge are CLI-generated random strings; constrain them to
// URL-safe base64 so they can be echoed into the loopback redirect untouched.
const URL_SAFE = /^[\w-]{16,128}$/;
@Injectable()
export class AuthService {
constructor(
private readonly oidc: OidcRepository,
private readonly jwt: JwtService,
private readonly wideContext: WideContextRepository,
) {}
async authenticate(headers: IncomingHttpHeaders): Promise<AuthDto> {
const bearer = headers.authorization?.match(/^Bearer (.+)$/i)?.[1];
if (bearer) {
return await this.authenticateCli(bearer);
}
const cookies = parse(headers.cookie ?? '');
const sub = cookies[CookieName.Sub];
const accessToken = cookies[CookieName.AccessToken];
@@ -88,4 +108,61 @@ export class AuthService {
accessToken: response.access_token,
};
}
private async authenticateCli(token: string): Promise<AuthDto> {
let payload: { sub: string };
try {
payload = await this.jwt.verifyAsync(token, { audience: JwtAudience.Cli });
} catch {
throw new UnauthorizedException('Invalid CLI session token');
}
this.wideContext.assignContext({ cliSub: payload.sub });
return { sub: payload.sub };
}
// Validates the loopback-flow query params of GET /auth/cli/login.
parseCliLoginParams(port?: string, state?: string, codeChallenge?: string): CliLoginParams {
const portNum = Number(port);
if (!Number.isInteger(portNum) || portNum < 1024 || portNum > 65_535) {
throw new BadRequestException('port must be an integer in [1024, 65535]');
}
if (!state || !URL_SAFE.test(state)) {
throw new BadRequestException('state must be 16-128 URL-safe base64 characters');
}
if (!codeChallenge || !URL_SAFE.test(codeChallenge)) {
throw new BadRequestException('code_challenge must be 16-128 URL-safe base64 characters');
}
return { port: portNum, state, codeChallenge };
}
// Mints the one-time code delivered to the CLI's loopback listener: a
// short-lived JWT binding the authenticated sub to the CLI's code_challenge.
// One-time use is enforced by PKCE semantics rather than server state — the
// code alone is useless without the verifier, which never leaves the CLI.
async mintCliCode(sub: string, codeChallenge: string): Promise<string> {
return await this.jwt.signAsync({ sub, cnf: codeChallenge }, { audience: JwtAudience.CliCode, expiresIn: '60s' });
}
async cliToken(code: string, codeVerifier: string): Promise<CliTokenResponseDto> {
let payload: { sub: string; cnf: string };
try {
payload = await this.jwt.verifyAsync(code, { audience: JwtAudience.CliCode });
} catch {
throw new UnauthorizedException('Invalid or expired CLI login code');
}
const challenge = createHash('sha256').update(codeVerifier).digest('base64url');
if (challenge !== payload.cnf) {
throw new UnauthorizedException('code_verifier does not match code_challenge');
}
const accessToken = await this.jwt.signAsync({ sub: payload.sub }, { audience: JwtAudience.Cli });
const { exp } = this.jwt.decode<{ exp: number }>(accessToken);
this.wideContext.assignContext({ cliSub: payload.sub });
return { accessToken, expiresAt: new Date(exp * 1000).toISOString(), sub: payload.sub };
}
}
@@ -2,6 +2,7 @@ import { MetricService } from '@common/server/otel';
import { INestApplication, ValidationPipe } from '@nestjs/common';
import { Test, TestingModule } from '@nestjs/testing';
import { parse } from 'cookie';
import { createHash } from 'node:crypto';
import { env } from 'src/env';
import request from 'supertest';
import { App } from 'supertest/types';
@@ -135,4 +136,70 @@ describe('AuthController (e2e)', () => {
);
});
});
describe('CLI loopback login flow', () => {
const verifier = 'integration-cli-verifier-0123456789';
const challenge = createHash('sha256').update(verifier).digest('base64url');
const state = 'integration-cli-state-0123456789';
it('rejects malformed loopback params', async () => {
await request(app.getHttpServer())
.get('/api/auth/cli/login')
.query({ port: '80', state, code_challenge: challenge })
.expect(400);
});
it('runs end-to-end: cli/login -> callback -> loopback code -> cli/token -> Bearer', async () => {
const { header } = await request(app.getHttpServer())
.get('/api/auth/cli/login')
.query({ port: '8123', state, code_challenge: challenge })
.expect(302);
const loginCookies = parse((header['set-cookie'] as never as string[]).join('; '));
expect(loginCookies['yucca-admin-cli-login']).toBeDefined();
const redirectUrl = new URL(header.location);
redirectUrl.pathname = '/api/form';
redirectUrl.searchParams.set('sub', 'cli-admin');
const { headers } = await fetch(redirectUrl, { redirect: 'manual' });
const callbackUrl = new URL(headers.get('location')!);
const { header: cbHeader } = await request(app.getHttpServer())
.get(callbackUrl.pathname + callbackUrl.search)
.set('Cookie', [
`yucca-admin-oidc-state=${loginCookies['yucca-admin-oidc-state']}`,
`yucca-admin-oidc-code-verifier=${loginCookies['yucca-admin-oidc-code-verifier']}`,
`yucca-admin-cli-login=${encodeURIComponent(loginCookies['yucca-admin-cli-login']!)}`,
])
.expect(302);
const loopback = new URL(cbHeader.location);
expect(loopback.origin).toBe('http://127.0.0.1:8123');
expect(loopback.pathname).toBe('/callback');
expect(loopback.searchParams.get('state')).toBe(state);
expect(cbHeader['set-cookie']).toEqual(
expect.arrayContaining([expect.stringContaining('yucca-admin-cli-login=;')]),
);
const code = loopback.searchParams.get('code')!;
// Wrong verifier is rejected; the right one yields a Bearer session.
await request(app.getHttpServer())
.post('/api/auth/cli/token')
.send({ code, codeVerifier: 'wrong-verifier' })
.expect(401);
const { body } = await request(app.getHttpServer())
.post('/api/auth/cli/token')
.send({ code, codeVerifier: verifier })
.expect(201);
expect(body.sub).toBe('cli-admin');
expect(body.accessToken).toBeDefined();
await request(app.getHttpServer())
.get('/api/auth')
.set('Authorization', `Bearer ${body.accessToken}`)
.expect(200)
.expect({ sub: 'cli-admin' });
});
});
});
+28 -31
View File
@@ -41,7 +41,7 @@ packages/yuctl/
context/ # ~/.config/yuctl/context.json {partition,region,ceph_cluster}
k8s/ # talosctl upgrade wrapper
ceph/ # RGW/dashboard health probe
adminapi/ # OIDC device flow + cookie-auth admin-api client
adminapi/ # CLI loopback login + Bearer admin-api client
```
## Command tree
@@ -49,6 +49,7 @@ packages/yuctl/
```
yuctl
├── select <partition>@<region> validate vs discovery → write context (clears ceph)
├── login browser loopback login → cached admin-api session JWT
├── ceph
│ ├── select <name> validate vs region's ceph_clusters keys → nest in context
│ └── get
@@ -57,7 +58,7 @@ yuctl
│ └── talos
│ └── upgrade talosctl upgrade CP nodes (--dry-run, confirm/--yes, --image)
└── users
└── list list users in the partition's PRIMARY region (UNTESTED)
└── list list users in the partition's PRIMARY region
```
Global flags: `--log-level` (trace|debug|info|warn|error), `--log-format`
@@ -110,35 +111,33 @@ yuctl ceph select sietch
yuctl ceph get health # → Ceph health against staging end-to-end
```
## `users list` — UNTESTED against a live admin-api
## `login` / `users list` — admin-api auth
`users list` is implemented to spec but **has not been exercised end-to-end**,
because it depends on out-of-band setup that does not exist yet:
`yuctl login` authenticates against the selected partition's admin-api using
the **CLI loopback login flow** — no IdP client secret ever reaches the
operator machine:
- a **public OIDC device client** registered for the admin scope (the device
client id today lives only for `yucca-api`, not the admin issuer), and
- **admin-api ingress exposure** — `yucca-admin-api` is in-cluster-only at the
moment.
1. Resolve the partition's **primary** region (`discovery.role == "primary"`)
and derive the admin-api base URL from its k8s `api_endpoint`
(`kube.<cluster>.<region>.<provider>.yucca.futo.network` →
`https://admin.<…>` — the same overlay host as `YUCCA_ADMIN_HOST`); override
with `--admin-url` or `YUCTL_ADMIN_API_URL`. The host is on the NetBird
overlay, so the operator (and their browser) must be connected.
2. Start a listener on `127.0.0.1:<random port>` and open the browser at
`GET /api/auth/cli/login?port&state&code_challenge` (S256 challenge; the
verifier never leaves yuctl). `--no-browser` prints the URL instead.
3. The admin-api — which owns the confidential OIDC client — runs its normal
browser OIDC dance, then redirects to the loopback listener with a
**one-time code**.
4. yuctl exchanges code + verifier at `POST /api/auth/cli/token` for an
admin-api-minted **24h ES256 session JWT**, cached at 0600
(`admin-token-<partition>.json`); `--reauth` forces a fresh login. The JWT
is sent as `Authorization: Bearer` and verified locally by the admin-api
(`packages/yucca-admin-api/src/services/auth.service.ts`).
What it does when those exist:
1. Resolve the partition's **primary** region (`discovery.role == "primary"`).
2. Derive the admin-api base URL from `region_meta.domain`
(`https://yucca-admin-api.<domain>`); override with `--admin-url` or
`YUCTL_ADMIN_API_URL`.
3. Run the **OAuth 2.0 device-authorization flow** against the Zitadel issuer
(`--issuer` / `OIDC_ADMIN_ISSUER`) using the public device client
(`--client-id` / `OIDC_ADMIN_DEVICE_CLIENT_ID`), print the verification
prompt, poll the token endpoint, and resolve the subject via OIDC userinfo.
The token is cached at 0600 (`admin-token-<partition>.json`); `--reauth`
forces a fresh login.
4. Call `GET /api/user` (cursor-paginated via `nextCursor`, `--limit` page size).
**Auth is COOKIE-based, not Bearer.** The admin-api validates the
`yucca-admin-sub` + `yucca-admin-access-token` cookies by calling OIDC userinfo
(`packages/yucca-admin-api/src/services/auth.service.ts`,
`src/middleware/auth.guard.ts`, cookie names in `src/enum.ts`). yuctl sends both
cookies and never an `Authorization` header.
`users list` reuses the cached session (running the same login flow when it is
missing or expired) and calls `GET /api/user` (cursor-paginated via
`nextCursor`, `--limit` page size).
## Environment variables
@@ -148,9 +147,7 @@ cookies and never an `Authorization` header.
| `YUCTL_TF_STATE_ACCESS_KEY_REF` / `…_SECRET_KEY_REF` | op refs for state creds | `op://yucca_tf/TF_STATE_S3_{ACCESS,SECRET}_KEY/password` |
| `YUCTL_TF_DEPLOYMENT_DIR` | force the local stack-enumeration dir | walk up for `tf/deployment` |
| `OP_BIN` | 1Password CLI binary | `op` |
| `OIDC_ADMIN_ISSUER` | admin OIDC issuer (`users list`) | — (flag `--issuer`) |
| `OIDC_ADMIN_DEVICE_CLIENT_ID` | public device client id (`users list`) | — (flag `--client-id`) |
| `YUCTL_ADMIN_API_URL` | admin-api base URL (`users list`) | derived from region domain |
| `YUCTL_ADMIN_API_URL` | admin-api base URL (`login`, `users`) | derived from discovery `api_endpoint` |
## Tests
+2 -2
View File
@@ -20,7 +20,7 @@ func tokenCachePath(partition string) (string, error) {
return filepath.Join(dir, fmt.Sprintf("admin-token-%s.json", partition)), nil
}
// LoadToken reads a cached device-flow token for the partition, or returns a
// LoadToken reads a cached CLI session token for the partition, or returns a
// zero Token (not an error) if none is cached.
func LoadToken(partition string) (Token, error) {
p, err := tokenCachePath(partition)
@@ -41,7 +41,7 @@ func LoadToken(partition string) (Token, error) {
return t, nil
}
// SaveToken persists a device-flow token at 0600.
// SaveToken persists a CLI session token at 0600.
func SaveToken(partition string, t Token) error {
dir, err := yctx.Dir()
if err != nil {
+147
View File
@@ -0,0 +1,147 @@
// Package adminapi authenticates against the yucca-admin-api via its CLI
// loopback login flow and queries its REST endpoints.
//
// Login never touches the IdP directly and needs no client secret: yuctl
// starts a listener on 127.0.0.1:<random port>, opens the browser at the
// admin-api's /api/auth/cli/login with a state nonce and an S256 code
// challenge, and the admin-api (which owns the confidential OIDC client) runs
// the normal browser OIDC dance. The callback redirects the browser to the
// loopback listener with a one-time code, which yuctl exchanges — together
// with the plaintext verifier that never left this process — for a
// admin-api-minted ES256 session JWT sent as `Authorization: Bearer`.
package adminapi
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"net"
"net/http"
"strings"
"time"
)
// Token is a cached admin-api CLI session: the minted JWT plus its subject and
// expiry (both also inside the JWT; duplicated for cheap validity checks).
type Token struct {
AccessToken string `json:"access_token"`
Sub string `json:"sub"`
Expiry time.Time `json:"expiry"`
}
// Valid reports whether the cached token is present and not expired.
func (t *Token) Valid() bool {
return t != nil && t.AccessToken != "" && t.Sub != "" && time.Now().Before(t.Expiry)
}
// loginTimeout bounds the wait for the operator to finish the browser flow.
const loginTimeout = 5 * time.Minute
type cliTokenResponse struct {
AccessToken string `json:"accessToken"`
ExpiresAt string `json:"expiresAt"`
Sub string `json:"sub"`
}
// BrowserLogin runs the loopback login flow against adminURL. promptFn
// receives the URL the operator must open (already attempted via openFn when
// non-nil; the prompt is always shown as fallback). Returns the minted session
// token.
func BrowserLogin(ctx context.Context, hc *http.Client, adminURL string, openFn func(url string) error, promptFn func(url string)) (*Token, error) {
verifier := randB64(32)
challenge := base64.RawURLEncoding.EncodeToString(func() []byte { s := sha256.Sum256([]byte(verifier)); return s[:] }())
state := randB64(16)
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
return nil, fmt.Errorf("start loopback listener: %w", err)
}
defer ln.Close()
port := ln.Addr().(*net.TCPAddr).Port
loginURL := fmt.Sprintf("%s/api/auth/cli/login?port=%d&state=%s&code_challenge=%s",
strings.TrimRight(adminURL, "/"), port, state, challenge)
codeCh := make(chan string, 1)
errCh := make(chan error, 1)
srv := &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/callback" {
http.NotFound(w, r)
return
}
if r.URL.Query().Get("state") != state {
http.Error(w, "state mismatch", http.StatusBadRequest)
errCh <- fmt.Errorf("loopback callback state mismatch")
return
}
code := r.URL.Query().Get("code")
if code == "" {
http.Error(w, "missing code", http.StatusBadRequest)
errCh <- fmt.Errorf("loopback callback missing code")
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprint(w, "<!doctype html><title>yuctl</title><body style=\"font-family:system-ui\"><p>Logged in — you can return to the terminal.</p></body>")
codeCh <- code
})}
go srv.Serve(ln) //nolint:errcheck // Serve always returns on Close; real failures surface via the timeout below.
defer srv.Close()
if openFn != nil {
_ = openFn(loginURL) // best-effort; the printed URL is the fallback
}
if promptFn != nil {
promptFn(loginURL)
}
var code string
select {
case <-ctx.Done():
return nil, ctx.Err()
case err := <-errCh:
return nil, err
case <-time.After(loginTimeout):
return nil, fmt.Errorf("timed out waiting for browser login")
case code = <-codeCh:
}
body, err := json.Marshal(map[string]string{"code": code, "codeVerifier": verifier})
if err != nil {
return nil, err
}
tokenURL := strings.TrimRight(adminURL, "/") + "/api/auth/cli/token"
req, err := http.NewRequestWithContext(ctx, http.MethodPost, tokenURL, strings.NewReader(string(body)))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
resp, err := hc.Do(req)
if err != nil {
return nil, fmt.Errorf("POST %s: %w", tokenURL, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
return nil, fmt.Errorf("POST %s: status %d", tokenURL, resp.StatusCode)
}
var tr cliTokenResponse
if err := json.NewDecoder(resp.Body).Decode(&tr); err != nil {
return nil, fmt.Errorf("parse token response: %w", err)
}
expiry, err := time.Parse(time.RFC3339, tr.ExpiresAt)
if err != nil {
return nil, fmt.Errorf("parse token expiry %q: %w", tr.ExpiresAt, err)
}
return &Token{AccessToken: tr.AccessToken, Sub: tr.Sub, Expiry: expiry}, nil
}
func randB64(n int) string {
b := make([]byte, n)
if _, err := rand.Read(b); err != nil {
panic(err) // crypto/rand never fails on supported platforms
}
return base64.RawURLEncoding.EncodeToString(b)
}
-214
View File
@@ -1,214 +0,0 @@
// Package adminapi authenticates against Zitadel via the OAuth 2.0 device
// authorization flow and queries the yucca-admin-api.
//
// IMPORTANT: yucca-admin-api auth is COOKIE-based, not Bearer. The API validates
// the `yucca-admin-sub` + `yucca-admin-access-token` cookies by calling OIDC
// userinfo (see packages/yucca-admin-api/src/services/auth.service.ts), so we
// send BOTH cookies — never an Authorization header.
//
// UNTESTED AGAINST A LIVE ADMIN-API: this depends on out-of-band setup that does
// not exist yet — (a) a public OIDC device client registered for the admin
// scope, and (b) admin-api ingress exposure (it is in-cluster-only today). The
// flow is implemented to spec but has not been exercised end-to-end.
package adminapi
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
"strconv"
"strings"
"time"
)
// oidcConfig is the subset of the OIDC discovery document we use.
type oidcConfig struct {
DeviceAuthorizationEndpoint string `json:"device_authorization_endpoint"`
TokenEndpoint string `json:"token_endpoint"`
UserinfoEndpoint string `json:"userinfo_endpoint"`
}
// Token is the result of a successful device-flow authentication: the OIDC
// access token plus the subject claim, which together form the admin-api
// cookies.
type Token struct {
AccessToken string `json:"access_token"`
Sub string `json:"sub"`
Expiry time.Time `json:"expiry"`
}
// Valid reports whether the cached token is present and not expired.
func (t *Token) Valid() bool {
return t != nil && t.AccessToken != "" && t.Sub != "" && time.Now().Before(t.Expiry)
}
type deviceAuthResponse struct {
DeviceCode string `json:"device_code"`
UserCode string `json:"user_code"`
VerificationURI string `json:"verification_uri"`
VerificationURIComplete string `json:"verification_uri_complete"`
ExpiresIn int `json:"expires_in"`
Interval int `json:"interval"`
}
type tokenResponse struct {
AccessToken string `json:"access_token"`
IDToken string `json:"id_token"`
ExpiresIn int `json:"expires_in"`
Error string `json:"error"`
}
type userinfoResponse struct {
Sub string `json:"sub"`
}
func discoverOIDC(ctx context.Context, hc *http.Client, issuer string) (*oidcConfig, error) {
well := strings.TrimRight(issuer, "/") + "/.well-known/openid-configuration"
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, well, nil)
resp, err := hc.Do(req)
if err != nil {
return nil, fmt.Errorf("fetch OIDC discovery %s: %w", well, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("OIDC discovery %s: status %d", well, resp.StatusCode)
}
var cfg oidcConfig
if err := json.NewDecoder(resp.Body).Decode(&cfg); err != nil {
return nil, fmt.Errorf("parse OIDC discovery: %w", err)
}
if cfg.DeviceAuthorizationEndpoint == "" || cfg.TokenEndpoint == "" {
return nil, fmt.Errorf("OIDC issuer %s does not advertise a device-authorization endpoint", issuer)
}
return &cfg, nil
}
// DeviceLogin runs the full device-authorization flow against issuer using the
// public device clientID, printing the verification prompt via promptFn and
// polling until the user approves. The returned Token carries the access token
// and the resolved subject (from userinfo).
func DeviceLogin(ctx context.Context, hc *http.Client, issuer, clientID, scope string, promptFn func(verificationURI, userCode, complete string)) (*Token, error) {
cfg, err := discoverOIDC(ctx, hc, issuer)
if err != nil {
return nil, err
}
form := url.Values{"client_id": {clientID}}
if scope != "" {
form.Set("scope", scope)
}
da, err := postForm[deviceAuthResponse](ctx, hc, cfg.DeviceAuthorizationEndpoint, form)
if err != nil {
return nil, fmt.Errorf("device authorization request: %w", err)
}
if promptFn != nil {
promptFn(da.VerificationURI, da.UserCode, da.VerificationURIComplete)
}
interval := da.Interval
if interval <= 0 {
interval = 5
}
deadline := time.Now().Add(time.Duration(maxInt(da.ExpiresIn, 300)) * time.Second)
for time.Now().Before(deadline) {
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(time.Duration(interval) * time.Second):
}
tokForm := url.Values{
"grant_type": {"urn:ietf:params:oauth:grant-type:device_code"},
"device_code": {da.DeviceCode},
"client_id": {clientID},
}
tr, err := postForm[tokenResponse](ctx, hc, cfg.TokenEndpoint, tokForm)
if err != nil {
return nil, err
}
switch tr.Error {
case "":
// success
case "authorization_pending":
continue
case "slow_down":
interval += 5
continue
default:
return nil, fmt.Errorf("device token error: %s", tr.Error)
}
sub, err := fetchSub(ctx, hc, cfg.UserinfoEndpoint, tr.AccessToken)
if err != nil {
return nil, err
}
exp := time.Now().Add(time.Duration(maxInt(tr.ExpiresIn, 300)) * time.Second)
return &Token{AccessToken: tr.AccessToken, Sub: sub, Expiry: exp}, nil
}
return nil, fmt.Errorf("device authorization timed out")
}
func fetchSub(ctx context.Context, hc *http.Client, userinfoEndpoint, accessToken string) (string, error) {
if userinfoEndpoint == "" {
return "", fmt.Errorf("OIDC issuer advertises no userinfo endpoint; cannot resolve subject")
}
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, userinfoEndpoint, nil)
req.Header.Set("Authorization", "Bearer "+accessToken)
resp, err := hc.Do(req)
if err != nil {
return "", fmt.Errorf("userinfo: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("userinfo: status %d", resp.StatusCode)
}
var ui userinfoResponse
if err := json.NewDecoder(resp.Body).Decode(&ui); err != nil {
return "", fmt.Errorf("parse userinfo: %w", err)
}
if ui.Sub == "" {
return "", fmt.Errorf("userinfo response missing sub")
}
return ui.Sub, nil
}
func postForm[T any](ctx context.Context, hc *http.Client, endpoint string, form url.Values) (*T, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(form.Encode()))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Accept", "application/json")
resp, err := hc.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
var out T
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return nil, fmt.Errorf("decode response from %s: %w", endpoint, err)
}
return &out, nil
}
func maxInt(a, b int) int {
if a > b {
return a
}
return b
}
// parseLimit validates a user-supplied page size for the admin-api.
func parseLimit(s string) (int, error) {
if s == "" {
return 0, nil
}
n, err := strconv.Atoi(s)
if err != nil || n < 1 {
return 0, fmt.Errorf("limit must be a positive integer")
}
return n, nil
}
+45 -14
View File
@@ -10,12 +10,6 @@ import (
"strings"
)
// Cookie names mirror packages/yucca-admin-api/src/enum.ts.
const (
cookieSub = "yucca-admin-sub"
cookieAccessToken = "yucca-admin-access-token"
)
// User mirrors the admin-api UserDto (src/dto/user.dto.ts).
type User struct {
ID string `json:"id"`
@@ -31,7 +25,7 @@ type userPage struct {
NextCursor *string `json:"nextCursor"`
}
// Client talks to one admin-api instance using cookie auth.
// Client talks to one admin-api instance using a CLI session JWT.
type Client struct {
baseURL string
http *http.Client
@@ -47,11 +41,39 @@ func NewClient(baseURL string, token Token, hc *http.Client) *Client {
return &Client{baseURL: strings.TrimRight(baseURL, "/"), http: hc, token: token}
}
// setAuth attaches BOTH auth cookies. The admin-api validates them via OIDC
// userinfo; a Bearer header would be ignored.
func (c *Client) setAuth(req *http.Request) {
req.AddCookie(&http.Cookie{Name: cookieSub, Value: c.token.Sub})
req.AddCookie(&http.Cookie{Name: cookieAccessToken, Value: c.token.AccessToken})
req.Header.Set("Authorization", "Bearer "+c.token.AccessToken)
}
// GetAuth verifies the session against GET /api/auth and returns the
// authenticated subject.
func (c *Client) GetAuth(ctx context.Context) (string, error) {
u := c.baseURL + "/api/auth"
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
if err != nil {
return "", err
}
req.Header.Set("Accept", "application/json")
c.setAuth(req)
resp, err := c.http.Do(req)
if err != nil {
return "", fmt.Errorf("GET %s: %w", u, err)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusUnauthorized {
return "", fmt.Errorf("admin-api rejected the session token (status 401) — run `yuctl login --reauth`")
}
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("GET %s: status %d", u, resp.StatusCode)
}
var out struct {
Sub string `json:"sub"`
}
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return "", fmt.Errorf("parse auth response: %w", err)
}
return out.Sub, nil
}
// ListUsers returns every user, following the cursor pagination. limit (when
@@ -99,7 +121,7 @@ func (c *Client) listUserPage(ctx context.Context, cursor string, limit int) (*u
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
return nil, fmt.Errorf("admin-api rejected cookies (status %d) — token may be expired or the device client lacks admin access", resp.StatusCode)
return nil, fmt.Errorf("admin-api rejected the session token (status %d) — run `yuctl login --reauth`", resp.StatusCode)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("GET %s: status %d", u, resp.StatusCode)
@@ -111,5 +133,14 @@ func (c *Client) listUserPage(ctx context.Context, cursor string, limit int) (*u
return &page, nil
}
// ParseLimit is the exported validator for the --limit flag.
func ParseLimit(s string) (int, error) { return parseLimit(s) }
// ParseLimit validates a user-supplied --limit page size for the admin-api.
func ParseLimit(s string) (int, error) {
if s == "" {
return 0, nil
}
n, err := strconv.Atoi(s)
if err != nil || n < 1 {
return 0, fmt.Errorf("limit must be a positive integer")
}
return n, nil
}
+169
View File
@@ -0,0 +1,169 @@
package cli
import (
"context"
"crypto/tls"
"fmt"
"net/http"
"net/url"
"os"
"os/exec"
"runtime"
"strings"
"time"
"github.com/spf13/cobra"
"yuctl/internal/adminapi"
yctx "yuctl/internal/context"
"yuctl/internal/discovery"
)
// adminFlags is the shared flag set for commands that talk to the admin-api.
type adminFlags struct {
adminURL string
insecure bool
reauth bool
noBrowser bool
}
func (f *adminFlags) register(c *cobra.Command) {
c.Flags().StringVar(&f.adminURL, "admin-url", "", "admin-api base URL (default: derived from discovery, or $YUCTL_ADMIN_API_URL)")
c.Flags().BoolVar(&f.insecure, "insecure-skip-tls-verify", false, "skip TLS verification")
c.Flags().BoolVar(&f.reauth, "reauth", false, "force a fresh browser login")
c.Flags().BoolVar(&f.noBrowser, "no-browser", false, "do not auto-open the browser; just print the login URL")
}
func (f *adminFlags) httpClient() *http.Client {
hc := &http.Client{Timeout: 30 * time.Second}
if f.insecure {
tr := http.DefaultTransport.(*http.Transport).Clone()
tr.TLSClientConfig = &tls.Config{InsecureSkipVerify: true}
hc.Transport = tr
}
return hc
}
// deriveAdminURL builds the admin-api origin for the partition's primary
// region from its k8s discovery payload: the Talos API endpoint lives at
// kube.<cluster>.<region>.<provider>.yucca.futo.network, and the admin-api is
// published on the same NetBird overlay zone as admin.<...> (the
// YUCCA_ADMIN_HOST cluster-setting follows the same convention).
func deriveAdminURL(topo *discovery.Topology, partition, region string) (string, error) {
k8s := topo.Kubernetes(partition, region)
if k8s == nil || k8s.APIEndpoint == "" {
return "", fmt.Errorf("no kubernetes discovery payload for %s@%s; pass --admin-url or set YUCTL_ADMIN_API_URL", partition, region)
}
u, err := url.Parse(k8s.APIEndpoint)
if err != nil {
return "", fmt.Errorf("parse api_endpoint %q: %w", k8s.APIEndpoint, err)
}
host, ok := strings.CutPrefix(u.Hostname(), "kube.")
if !ok {
return "", fmt.Errorf("api_endpoint host %q does not start with kube.; pass --admin-url or set YUCTL_ADMIN_API_URL", u.Hostname())
}
return "https://admin." + host, nil
}
// resolveAdminURL applies the override chain: --admin-url > $YUCTL_ADMIN_API_URL
// > derived from the primary region's discovery.
func (f *adminFlags) resolveAdminURL(topo *discovery.Topology, cc *yctx.Context) (string, error) {
if f.adminURL != "" {
return f.adminURL, nil
}
if env := os.Getenv("YUCTL_ADMIN_API_URL"); env != "" {
return env, nil
}
primary := topo.PrimaryRegion(cc.Partition)
if primary == "" {
return "", fmt.Errorf("no primary region found for partition %q (no stack with discovery.role==\"primary\")", cc.Partition)
}
return deriveAdminURL(topo, cc.Partition, primary)
}
// adminLogin returns an authenticated admin-api client, reusing the cached
// per-partition session when valid and running the browser loopback flow
// otherwise.
func (f *adminFlags) adminLogin(ctx context.Context, cmd *cobra.Command, cc *yctx.Context, topo *discovery.Topology) (*adminapi.Client, *adminapi.Token, error) {
adminURL, err := f.resolveAdminURL(topo, cc)
if err != nil {
return nil, nil, err
}
hc := f.httpClient()
token, err := adminapi.LoadToken(cc.Partition)
if err != nil {
return nil, nil, err
}
if f.reauth || !token.Valid() {
var openFn func(string) error
if !f.noBrowser {
openFn = openBrowser
}
fresh, err := adminapi.BrowserLogin(ctx, hc, adminURL, openFn, func(loginURL string) {
out := cmd.ErrOrStderr()
fmt.Fprintln(out, "Complete the login in your browser:")
fmt.Fprintf(out, " %s\n", loginURL)
})
if err != nil {
return nil, nil, fmt.Errorf("browser login: %w", err)
}
token = *fresh
if err := adminapi.SaveToken(cc.Partition, token); err != nil {
return nil, nil, err
}
}
return adminapi.NewClient(adminURL, token, hc), &token, nil
}
// openBrowser opens url in the OS default browser, best-effort.
func openBrowser(url string) error {
switch runtime.GOOS {
case "darwin":
return exec.Command("open", url).Start()
default:
return exec.Command("xdg-open", url).Start()
}
}
func newLoginCmd() *cobra.Command {
flags := &adminFlags{}
c := &cobra.Command{
Use: "login",
Short: "Log in to the partition's yucca-admin-api via the browser",
Long: "Authenticate against the selected partition's admin-api (primary region):\n" +
"opens the admin-api CLI login in your browser, receives a one-time code on a\n" +
"127.0.0.1 listener, exchanges it for a 24h session JWT, and caches it at\n" +
"${XDG_CONFIG_HOME:-~/.config}/yuctl/admin-token-<partition>.json.",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
ctx := cmd.Context()
cc, err := requireContext()
if err != nil {
return err
}
topo, err := resolveTopology(ctx)
if err != nil {
return err
}
client, token, err := flags.adminLogin(ctx, cmd, cc, topo)
if err != nil {
return err
}
// Round-trip the session so "login" only succeeds when the API
// actually accepts the token.
sub, err := client.GetAuth(ctx)
if err != nil {
return err
}
fmt.Fprintf(cmd.OutOrStdout(), "logged in to %s as %s (expires %s)\n",
cc.Partition, sub, token.Expiry.Local().Format(time.RFC3339))
return nil
},
}
flags.register(c)
return c
}
+1
View File
@@ -42,6 +42,7 @@ func NewRootCmd() *cobra.Command {
root.AddCommand(
newSelectCmd(),
newLoginCmd(),
newCephCmd(),
newInfraCmd(),
newUsersCmd(),
+7 -93
View File
@@ -1,12 +1,8 @@
package cli
import (
"crypto/tls"
"fmt"
"net/http"
"os"
"text/tabwriter"
"time"
"github.com/spf13/cobra"
@@ -14,34 +10,21 @@ import (
)
// newUsersCmd builds the `users` subtree.
//
// NOTE: `users list` is UNTESTED against a live admin-api. It depends on
// out-of-band setup that does not exist yet: a public OIDC device client for the
// admin scope, and admin-api ingress exposure (in-cluster-only today). The flow
// is implemented to spec (device-authorization → cookie auth → cursor
// pagination) but has not been exercised end-to-end.
func newUsersCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "users",
Short: "User administration via yucca-admin-api (UNTESTED: needs admin OIDC client + ingress)",
Short: "User administration via yucca-admin-api",
}
cmd.AddCommand(newUsersListCmd())
return cmd
}
func newUsersListCmd() *cobra.Command {
var (
issuerFlag string
clientIDFlag string
scopeFlag string
adminURLFlag string
limitFlag string
insecure bool
reauth bool
)
flags := &adminFlags{}
var limitFlag string
c := &cobra.Command{
Use: "list",
Short: "List users in the partition's primary region (UNTESTED end-to-end)",
Short: "List users in the partition's primary region",
RunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
cc, err := requireContext()
@@ -54,71 +37,16 @@ func newUsersListCmd() *cobra.Command {
return err
}
// Resolve the partition's PRIMARY region (discovery.role=="primary").
topo, err := resolveTopology(ctx)
if err != nil {
return err
}
primary := topo.PrimaryRegion(cc.Partition)
if primary == "" {
return fmt.Errorf("no primary region found for partition %q (no stack with discovery.role==\"primary\")", cc.Partition)
}
// Derive the admin-api base URL (override > derived from region domain).
adminURL := adminURLFlag
if adminURL == "" {
adminURL = os.Getenv("YUCTL_ADMIN_API_URL")
}
if adminURL == "" {
meta, ok := topo.RegionMeta(cc.Partition, primary)
if !ok || meta.Domain == "" {
return fmt.Errorf("cannot derive admin-api URL: region_meta.domain is empty for %s@%s; pass --admin-url or set YUCTL_ADMIN_API_URL", cc.Partition, primary)
}
adminURL = "https://yucca-admin-api." + meta.Domain
}
issuer := firstNonEmpty(issuerFlag, os.Getenv("OIDC_ADMIN_ISSUER"))
if issuer == "" {
return fmt.Errorf("OIDC issuer required: pass --issuer or set OIDC_ADMIN_ISSUER")
}
clientID := firstNonEmpty(clientIDFlag, os.Getenv("OIDC_ADMIN_DEVICE_CLIENT_ID"))
if clientID == "" {
return fmt.Errorf("OIDC device client id required: pass --client-id or set OIDC_ADMIN_DEVICE_CLIENT_ID")
}
scope := firstNonEmpty(scopeFlag, "openid profile email")
hc := &http.Client{Timeout: 30 * time.Second}
if insecure {
tr := http.DefaultTransport.(*http.Transport).Clone()
tr.TLSClientConfig = &tls.Config{InsecureSkipVerify: true}
hc.Transport = tr
}
// Reuse a cached token unless expired or --reauth.
token, err := adminapi.LoadToken(cc.Partition)
client, _, err := flags.adminLogin(ctx, cmd, cc, topo)
if err != nil {
return err
}
if reauth || !token.Valid() {
fresh, err := adminapi.DeviceLogin(ctx, hc, issuer, clientID, scope, func(verificationURI, userCode, complete string) {
out := cmd.ErrOrStderr()
fmt.Fprintln(out, "To authenticate, open the following URL and enter the code:")
if complete != "" {
fmt.Fprintf(out, " %s\n", complete)
}
fmt.Fprintf(out, " URL: %s\n", verificationURI)
fmt.Fprintf(out, " code: %s\n", userCode)
})
if err != nil {
return fmt.Errorf("device login: %w", err)
}
token = *fresh
if err := adminapi.SaveToken(cc.Partition, token); err != nil {
return err
}
}
client := adminapi.NewClient(adminURL, token, hc)
users, err := client.ListUsers(ctx, limit)
if err != nil {
return err
@@ -130,25 +58,11 @@ func newUsersListCmd() *cobra.Command {
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%t\n", u.ID, u.Sub, u.Name, u.Email, u.Disabled)
}
w.Flush()
fmt.Fprintf(cmd.ErrOrStderr(), "\n%d user(s) in %s@%s (primary)\n", len(users), cc.Partition, primary)
fmt.Fprintf(cmd.ErrOrStderr(), "\n%d user(s) in partition %s\n", len(users), cc.Partition)
return nil
},
}
c.Flags().StringVar(&issuerFlag, "issuer", "", "OIDC issuer URL (default: $OIDC_ADMIN_ISSUER)")
c.Flags().StringVar(&clientIDFlag, "client-id", "", "public OIDC device client id (default: $OIDC_ADMIN_DEVICE_CLIENT_ID)")
c.Flags().StringVar(&scopeFlag, "scope", "", "OIDC scope (default: openid profile email)")
c.Flags().StringVar(&adminURLFlag, "admin-url", "", "admin-api base URL (default: derived from region domain or $YUCTL_ADMIN_API_URL)")
c.Flags().StringVar(&limitFlag, "limit", "", "page size for the admin-api (default: server default)")
c.Flags().BoolVar(&insecure, "insecure-skip-tls-verify", false, "skip TLS verification")
c.Flags().BoolVar(&reauth, "reauth", false, "force a fresh device-authorization login")
flags.register(c)
return c
}
func firstNonEmpty(vals ...string) string {
for _, v := range vals {
if v != "" {
return v
}
}
return ""
}
+20 -13
View File
@@ -35,7 +35,7 @@ type Discovery struct {
// RegionMeta is the per-region metadata merged in from region.hcl.
type RegionMeta struct {
SiteID string `json:"site_id"`
SiteID *int `json:"site_id"` // numeric fabric site id; null for non-fabric sites (austin)
Datacenter string `json:"datacenter"`
ProviderCode string `json:"provider_code"`
Domain string `json:"domain"`
@@ -59,7 +59,7 @@ type CephCluster struct {
RGWS3Endpoint string `json:"rgw_s3_endpoint"`
HealthCredRef string `json:"health_cred_ref"` // op:// reference
S3AdminCredRefs map[string]string `json:"s3_admin_cred_refs"` // op:// references
SecretItemTitles []string `json:"secret_item_titles"`
SecretItemTitles map[string]string `json:"secret_item_titles"` // purpose → 1P item title
BootstrapHost string `json:"bootstrap_host"`
}
@@ -71,22 +71,29 @@ type DNS struct {
APITokenRef string `json:"api_token_ref"` // op:// reference
}
// Netbird is the netbird / global stack payload.
// Netbird is the netbird / global stack payload. All maps are keyed by the
// friendly resource name (e.g. group "ceph", network "HTZ-FSN1").
type Netbird struct {
NamePrefix string `json:"name_prefix"`
Vault string `json:"vault"`
GroupIDs []string `json:"group_ids"`
PolicyIDs []string `json:"policy_ids"`
NetworkIDs []string `json:"network_ids"`
SetupKeyItemTitles []string `json:"setup_key_item_titles"`
NamePrefix string `json:"name_prefix"`
Vault string `json:"vault"`
GroupIDs map[string]string `json:"group_ids"`
PolicyIDs map[string]string `json:"policy_ids"`
NetworkIDs map[string]string `json:"network_ids"`
SetupKeyItemTitles map[string]string `json:"setup_key_item_titles"`
}
// ClusterCIDR is one fabric cluster's public/private CIDR pair.
type ClusterCIDR struct {
Public string `json:"public"`
Private string `json:"private"`
}
// Fabric is the fabric stack payload.
type Fabric struct {
SiteID string `json:"site_id"`
KubeCIDR string `json:"kube_cidr"`
MgmtCIDR string `json:"mgmt_cidr"`
ClusterCIDRs []string `json:"cluster_cidrs"`
SiteID *int `json:"site_id"`
KubeCIDR string `json:"kube_cidr"`
MgmtCIDR string `json:"mgmt_cidr"`
ClusterCIDRs map[string]ClusterCIDR `json:"cluster_cidrs"` // keyed by ceph cluster slug (e.g. "cls1")
}
// tfState is the minimal slice of a terraform.tfstate JSON document we care
@@ -16,7 +16,7 @@ const cephState = `{
"role": "primary",
"stack": "ceph",
"stack_type": "ceph",
"region_meta": {"site_id": "austin", "datacenter": "austin", "provider_code": "aus", "domain": "staging.example.com"},
"region_meta": {"site_id": null, "datacenter": "austin", "provider_code": "aus", "domain": "staging.example.com"},
"ceph_clusters": {
"sietch": {
"cluster_name": "sietch",
@@ -24,7 +24,7 @@ const cephState = `{
"rgw_s3_endpoint": "https://s3.sietch.staging.austin.int",
"health_cred_ref": "op://yucca_tf_staging/SIETCH_HEALTH/password",
"s3_admin_cred_refs": {"access": "op://v/i/access", "secret": "op://v/i/secret"},
"secret_item_titles": ["SIETCH_HEALTH"],
"secret_item_titles": {"health": "SIETCH_HEALTH"},
"bootstrap_host": "ceph-1.sietch"
}
}
@@ -85,6 +85,35 @@ resource "onepassword_item" "yucca_jwt" {
}
}
# ES256 keypair for yucca-admin-api's CLI session JWTs (yuctl login). Separate
# trust domain from yucca_jwt on purpose: admin-api both signs and verifies,
# and nothing else may accept these tokens.
resource "tls_private_key" "yucca_admin_jwt" {
algorithm = "ECDSA"
ecdsa_curve = "P256"
lifecycle {
prevent_destroy = true
}
}
resource "onepassword_item" "yucca_admin_jwt" {
vault = data.onepassword_vault.prod.uuid
title = "YUCCA_ADMIN_JWT_KEYPAIR"
category = "password"
password = tls_private_key.yucca_admin_jwt.private_key_pem_pkcs8
section {
label = "keypair"
field {
label = "public_key"
type = "STRING"
value = tls_private_key.yucca_admin_jwt.public_key_pem
}
}
}
# Namespaces created here so the Secrets have a home before Flux reconciles;
# the Flux overlays declare them too (bare Namespace is safe under dual SSA).
resource "kubernetes_namespace_v1" "yucca" {
@@ -129,6 +158,7 @@ resource "kubernetes_secret_v1" "yucca_admin_api" {
namespace = kubernetes_namespace_v1.yucca.metadata[0].name
}
data = {
JWT_PRIVATE_KEY = tls_private_key.yucca_admin_jwt.private_key_pem_pkcs8
OIDC_ADMIN_CLIENT_ID = var.yucca_oidc_admin_client_id
OIDC_ADMIN_CLIENT_SECRET = var.yucca_oidc_admin_client_secret
}
@@ -64,6 +64,33 @@ resource "onepassword_item" "yucca_jwt" {
}
}
# ES256 keypair for yucca-admin-api's CLI session JWTs (yuctl login). Separate
# trust domain from yucca_jwt on purpose: admin-api both signs and verifies,
# and nothing else may accept these tokens.
resource "tls_private_key" "yucca_admin_jwt" {
count = local.provision_secrets ? 1 : 0
algorithm = "ECDSA"
ecdsa_curve = "P256"
}
resource "onepassword_item" "yucca_admin_jwt" {
count = local.provision_secrets ? 1 : 0
vault = data.onepassword_vault.staging[0].uuid
title = "YUCCA_ADMIN_JWT_KEYPAIR"
category = "password"
password = tls_private_key.yucca_admin_jwt[0].private_key_pem_pkcs8
section {
label = "keypair"
field {
label = "public_key"
type = "STRING"
value = tls_private_key.yucca_admin_jwt[0].public_key_pem
}
}
}
# ─── Cluster access (recorded in 1P) ────────────────────────────────────
# kubeconfig + talosconfig, so operators fetch them with `op read` instead of
# pulling TF state.
@@ -145,6 +172,7 @@ resource "kubernetes_secret_v1" "yucca_admin_api" {
namespace = kubernetes_namespace_v1.yucca[0].metadata[0].name
}
data = {
JWT_PRIVATE_KEY = tls_private_key.yucca_admin_jwt[0].private_key_pem_pkcs8
OIDC_ADMIN_CLIENT_ID = var.yucca_oidc_admin_client_id
OIDC_ADMIN_CLIENT_SECRET = var.yucca_oidc_admin_client_secret
}