From 3c5be606678beca7054e0183bf4e183ca67316f8 Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Thu, 17 Sep 2026 01:02:35 +1000 Subject: [PATCH] feat(identity): store the device label the CLI sends at sign-in The devices page already shows device_label, but the token exchange never accepted one, so every CLI sign-in showed only its token kind. --- .../server/api/cli/oauth/token.post.ts | 9 +++++- test/unit/cli-oauth-endpoints.test.ts | 29 +++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/layers/identity/server/api/cli/oauth/token.post.ts b/layers/identity/server/api/cli/oauth/token.post.ts index 4017e02c..fff31e97 100644 --- a/layers/identity/server/api/cli/oauth/token.post.ts +++ b/layers/identity/server/api/cli/oauth/token.post.ts @@ -1,4 +1,5 @@ import { OauthTokenInputSchema, TokenResponseSchema } from 'skilld-protocol/wire' +import { z } from 'zod' import { defineApiHandler } from '#shared/server/handler' import { issueSession, presentTokenResponse, sha256Base64Url } from '../../../utils/cli-tokens' import { getUserById } from '../../../utils/users' @@ -14,8 +15,13 @@ interface AuthCodeRow { used_at: number | null } +// The CLI names each sign-in with the computer hostname. Only the account owner sees it. +const TokenInputSchema = OauthTokenInputSchema.extend({ + device_label: z.string().trim().min(1).max(64).regex(/^\P{Cc}+$/u).optional(), +}) + export default defineApiHandler({ - schema: OauthTokenInputSchema, + schema: TokenInputSchema, response: TokenResponseSchema, handler: async ({ event, body }) => { const row = await event.context.platform.db.prepare( @@ -46,6 +52,7 @@ export default defineApiHandler({ kind: 'oauth', scopes: row.scopes, cliVersion: row.cli_version ?? undefined, + deviceLabel: body.device_label, }) return presentTokenResponse(session, user.login) diff --git a/test/unit/cli-oauth-endpoints.test.ts b/test/unit/cli-oauth-endpoints.test.ts index b5a5994f..bc3a61f9 100644 --- a/test/unit/cli-oauth-endpoints.test.ts +++ b/test/unit/cli-oauth-endpoints.test.ts @@ -133,6 +133,35 @@ describe('cli OAuth token endpoint', () => { }) }) + it('stores the device label the CLI sends', async () => { + const verifier = 'd'.repeat(64) + await insertCode({ code: 'code-label-1234567', challenge: await sha256Base64Url(verifier), port: 49155 }) + + setBody({ + code: 'code-label-1234567', + code_verifier: verifier, + redirect_uri: 'http://127.0.0.1:49155/', + device_label: ' work-laptop ', + }) + await tokenHandler(event) + + expect(sqlite.prepare('SELECT device_label FROM cli_tokens').get()).toEqual({ device_label: 'work-laptop' }) + }) + + it('rejects a device label with control characters', async () => { + const verifier = 'e'.repeat(64) + await insertCode({ code: 'code-ctrl-12345678', challenge: await sha256Base64Url(verifier), port: 49156 }) + + setBody({ + code: 'code-ctrl-12345678', + code_verifier: verifier, + redirect_uri: 'http://127.0.0.1:49156/', + device_label: 'work\u001B[31mlaptop', + }) + + await expect(tokenHandler(event)).rejects.toMatchObject({ statusCode: 400 }) + }) + async function insertCode(input: { code: string, challenge: string, port: number }) { sqlite.prepare(` INSERT INTO cli_auth_codes (