mirror of
https://github.com/immich-app/yucca.git
synced 2026-09-30 13:33:00 +08:00
feat(admin): make admin go brr (#327)
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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" . }}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -42,6 +42,7 @@ func NewRootCmd() *cobra.Command {
|
||||
|
||||
root.AddCommand(
|
||||
newSelectCmd(),
|
||||
newLoginCmd(),
|
||||
newCephCmd(),
|
||||
newInfraCmd(),
|
||||
newUsersCmd(),
|
||||
|
||||
@@ -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 ""
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user