@mostajs/auth-flow
v0.1.0-alpha.3
Published
OAuth 2.0 Device Authorization Grant (RFC 8628) + Authorization Code with PKCE (RFC 8252) — provider-agnostic, reusable across CLIs / SDKs / mobile apps. Token issuance delegated via DI to host (e.g. @mostajs/api-keys).
Maintainers
Readme
@mostajs/auth-flow
v0.1.0-alpha — OAuth 2.0 Device Authorization Grant (RFC 8628) + Authorization Code Flow with PKCE (RFC 8252), provider-agnostic, réutilisable dans tout CLI / SDK / app mobile / agent embarqué.
Auteur : Dr Hamid MADANI [email protected]
Statut : alpha — interfaces gelées, implémentation à venir (cf. docs/PLAN.md).
Pourquoi ce module
Quand un client (CLI, SDK serveur, app mobile, agent IA) doit s'authentifier auprès d'un service distant sans mot de passe stocké en local et sans formulaire dans le terminal, le standard 2025 est l'OAuth 2.0 Device Flow + PKCE — exactement le pattern qu'utilisent Claude Code, GitHub CLI, Stripe CLI, gcloud, AWS SSO, Vercel.
@mostajs/auth-flow implémente ces deux RFC côté client ET côté serveur, sans connaître la nature du token émis (apikey opaque, JWT, autre) ni la nature de l'utilisateur (free, paid, anonyme). Tout est DI : le host branche les pièces qui lui sont propres.
Architecture
┌──────────────────────────────────────────────────────────────────────────┐
│ │
│ CLIENT (CLI / SDK) │
│ ───────────────── │
│ import { createDeviceFlow } from '@mostajs/auth-flow/client' │
│ │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ createDeviceFlow({ │ │
│ │ host: 'octonet', │ │
│ │ authorizeEndpoint, tokenEndpoint, clientId, scope, │ │
│ │ onCodeIssued(code, uri), │ │
│ │ store: createFsAuthStore(), │ │
│ │ }) │ │
│ └────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ HTTP │
│ ───────────────────────────────────────────────────────── │
│ SERVER (Octonet / autre service) │
│ ───────────────────────────────── │
│ import { createDeviceFlowHandlers } from '@mostajs/auth-flow/server' │
│ │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ createDeviceFlowHandlers({ │ │
│ │ issueAccessToken: ({accountId, scopes, clientId}) │ │
│ │ → { token, expiresIn }, ← @mostajs/api-keys │
│ │ resolveUserSession(req), ← @mostajs/auth │
│ │ onAnonymousVisitor(deviceCode, req), ← @mostajs/payment │
│ │ pendingStore: createOrmPendingStore(), │
│ │ }) │ │
│ └────────────────────────────────────────────────────────┘ │
│ │
│ POST /device/authorize → { device_code, user_code, verification_uri } │
│ POST /device/token → polling — { access_token } once approved │
│ GET /device → page HTML (vanilla, override-able) │
│ POST /device/approve → endpoint appelé par /device après consent │
│ │
└──────────────────────────────────────────────────────────────────────────┘Diagramme de séquence — Device Flow (RFC 8628)
┌────────────┐ ┌──────────────┐ ┌────────┐ ┌────────────┐
│ CLIENT CLI │ │ AUTH-FLOW │ │ BROWSER│ │ USER │
│ (data-plug)│ │ SERVER │ │ (mobile│ │ │
│ │ │ (octonet-cl) │ │ / desk)│ │ │
└─────┬──────┘ └──────┬───────┘ └───┬────┘ └─────┬──────┘
│ │ │ │
│ ① POST /device/ │ │ │
│ authorize │ │ │
│ {client_id, scope} │ │ │
├──────────────────────▶ │ │ │
│ │ ⓐ generate │ │
│ │ device_code │ │
│ │ user_code │ │
│ │ pendingStore.put │ │
│ ② DeviceCodeResponse │ │ │
│ {device_code, │ │ │
│ user_code: "WDJB-MJHT"│ │ │
│ verification_uri, │ │ │
│ expires_in, interval} │ │ │
│ ◀──────────────────────│ │ │
│ │ │ │
│ ③ display URL + code │ │ │
│ to user (terminal) │ │ │
├──────────────────────────────────────────────────────────────────▶│
│ │ │ │
│ ④ POLL POST /device/ │ │ │
│ token (interval=5s) │ │ │
├──────────────────────▶ │ │ │
│ {error: "auth_pending"}│ │ │
│ ◀──────────────────────│ │ │
│ │ │ ⑤ open verif.URI │
│ │ │ ◀──────────────────│
│ │ │ │
│ │ ⑥ GET /device │ │
│ │ ◀───────────────────│ │
│ │ HTML page (vanilla) │ │
│ │ ───────────────────▶│ │
│ │ │ │
│ │ ⑦ user enters │ │
│ │ user_code │ │
│ │ ⑧ resolveUser │ │
│ │ Session(req) │ │
│ │ ⓑ if anonymous: │ │
│ │ onAnonymous │ │
│ │ Visitor() → │ │
│ │ signup/Stripe │ │
│ │ via @mostajs/ │ │
│ │ payment │ │
│ │ │ │
│ │ ⑨ POST /approve │ │
│ │ ◀───────────────────│ │
│ │ ⓒ pendingStore │ │
│ │ .approve(code, │ │
│ │ accountId) │ │
│ │ "Return to your │ │
│ │ client" ───────────▶│ │
│ │ │ │
│ ⑩ POLL /device/token │ │ │
├──────────────────────▶ │ │ │
│ │ ⓓ pendingStore │ │
│ │ .consume() │ │
│ │ ⓔ issueAccessToken │ │
│ │ ({accountId, │ │
│ │ scopes, │ │
│ │ clientId}) │ │
│ │ → @mostajs/ │ │
│ │ api-keys. │ │
│ │ create() │ │
│ ⑪ TokenResponse │ │ │
│ {access_token, │ │ │
│ expires_in, │ │ │
│ token_type:"Bearer"} │ │ │
│ ◀──────────────────────│ │ │
│ │ │ │
│ ⑫ store.save(...) │ │ │
│ ~/.config/<host>/ │ │ │
│ auth.json (mode 600) │ │ │
│ │ │ │
│ ✓ ready — call API │ │ │
│ with access_token │ │ │Diagramme de séquence — PKCE Authorization Code Flow (RFC 8252 + 7636)
Mode optionnel pour CLI desktop capable d'ouvrir un browser ET d'écouter sur localhost.
┌────────────┐ ┌──────────────┐ ┌────────┐ ┌────────────┐
│ CLIENT CLI │ │ AUTH-FLOW │ │ BROWSER│ │ USER │
└─────┬──────┘ └──────┬───────┘ └───┬────┘ └─────┬──────┘
│ │ │ │
│ ① generate code_ │ │ │
│ verifier + state │ │ │
│ (via @mostajs/auth/ │ │ │
│ lib/oauth-primitives)│ │ │
│ │ │ │
│ ② start http:// │ │ │
│ localhost:PORT │ │ │
│ listener │ │ │
│ │ │ │
│ ③ exec open BROWSER │ │ │
│ GET /oauth/authorize│ │ │
│ ?response_type=code │ │ │
│ &client_id=... │ │ │
│ &redirect_uri= │ │ │
│ localhost:PORT │ │ │
│ &state=... │ │ │
│ &code_challenge=... │ │ │
│ &code_challenge_ │ │ │
│ method=S256 │ │ │
├──────────────────────────────────────────────▶ │
│ │ │ │
│ │ ④ user authenticates│ │
│ │ ◀───────────────────┼────────────────────┤
│ │ │ │
│ │ ⑤ 302 redirect │ │
│ │ localhost:PORT/ │ │
│ │ callback? │ │
│ │ code=XYZ │ │
│ │ &state=... │ │
│ │ ───────────────────▶│ │
│ │ │ │
│ ⑥ GET callback? │ │ │
│ code=XYZ │ │ │
│ ◀──────────────────────────────────────────│ │
│ verify state match │ │ │
│ │ │ │
│ ⑦ POST /oauth/token │ │ │
│ {code, code_verifier│ │ │
│ redirect_uri, │ │ │
│ client_id} │ │ │
├──────────────────────▶│ │ │
│ │ ⓐ verify SHA-256 │ │
│ │ (code_verifier) │ │
│ │ == challenge │ │
│ │ ⓑ issueAccessToken │ │
│ ⑧ TokenResponse │ │ │
│ ◀─────────────────────│ │ │
│ │ │ │
│ ⑨ store.save(...) │ │ │Découplage strict
@mostajs/auth-flow ne dépend de NI :
@mostajs/api-keys(le host la branche viaissueAccessToken)@mostajs/auth(le host la branche viaresolveUserSession)@mostajs/payment(le host la branche viaonAnonymousVisitor)@mostajs/orm(option : consumer peut câbler unePendingStoreORM-backed, mais le module fournit aussi une in-memory)
Le module est autonome — quelques fetch, crypto.randomUUID(), des interfaces. C'est ce qui le rend ré-utilisable hors-Octonet pour de futurs projets.
RFC implementées
| Spec | Périmètre | Quand |
|---|---|---|
| RFC 8628 — OAuth 2.0 Device Authorization Grant | CLI / serveur / mobile / SSH / headless / context où on ne peut pas (ou ne veut pas) ouvrir un browser local | Toujours — défaut universel |
| RFC 8252 — OAuth 2.0 for Native Apps + PKCE | Desktop CLI capable de spawn un browser ET d'écouter sur localhost:PORT | Optionnel — UX optimisée desktop |
| RFC 7636 — PKCE (Proof Key for Code Exchange) | Composant cryptographique du PKCE flow | Inclus dans le PKCE flow |
Le module v0.1 livre les deux flows en parallèle (R1 du plan : pas de dette technique) — le client choisit selon son contexte d'exécution.
Quick start (côté client — CLI / SDK)
import { createDeviceFlow, createFsAuthStore } from '@mostajs/auth-flow/client'
const flow = createDeviceFlow({
host: 'octonet', // → ~/.config/octonet/auth.json (par défaut)
authorizeEndpoint: 'https://octonet.cloud/api/v1/auth/device/authorize',
tokenEndpoint: 'https://octonet.cloud/api/v1/auth/device/token',
clientId: 'data-plug-cli',
scope: ['octonet.read'],
onCodeIssued: ({ user_code, verification_uri, expires_in }) => {
console.log(`\n Open ${verification_uri}`)
console.log(` Enter code: ${user_code}`)
console.log(` Expires in ${Math.round(expires_in / 60)} min...\n`)
},
store: createFsAuthStore(), // ~/.config/<host>/auth.json (XDG-compliant, mode 600)
})
const token = await flow.run() // bootstrap le device flow + persiste le token
// → token.access_token est utilisable comme apikeySi un token valide est déjà en ~/.config/octonet/auth.json, flow.run() le retourne immédiatement (pas de re-prompt).
Quick start (côté serveur — host : Octonet)
import { createDeviceFlowHandlers } from '@mostajs/auth-flow/server'
import { apiKeys } from '@mostajs/api-keys' // host's choice
import { resolveSession } from './auth' // host's choice
const handlers = createDeviceFlowHandlers({
issueAccessToken: async ({ accountId, scopes, clientId, deviceCode }) => {
const generated = await apiKeys.create({
accountId,
label: `auth-flow:${clientId}`,
permissions: {
scopes: scopes.reduce((m, s) => ({ ...m, [s]: '*' }), {}),
metadata: { issuedVia: 'device-flow', clientId, deviceCode },
},
expiresAt: new Date(Date.now() + 365 * 86400_000),
})
return { token: generated.full, expiresIn: 365 * 86400 }
},
resolveUserSession: (req) => resolveSession(req), // → { accountId } | null
onAnonymousVisitor: ({ deviceCode, req, res }) => {
// free / paid choice — host's call
return res.redirect(`/signup?next=/device&code=${deviceCode}`)
},
pendingStore: createOrmPendingStore(), // or createMemoryPendingStore() for tests
defaultPage: 'vanilla', // 'vanilla' = bundled HTML | 'none' = host serves its own
})
// Route wiring (Express / Fastify / Next.js — adapter your framework)
app.post('/api/v1/auth/device/authorize', handlers.authorize)
app.post('/api/v1/auth/device/token', handlers.token)
app.get ('/device', handlers.devicePage)
app.post('/api/v1/auth/device/approve', handlers.approve)Méthodes de connexion — guide d'utilisation détaillé
Trois méthodes couvertes par le module, plus une situation cached (re-utilisation token déjà obtenu). À chaque méthode son contexte cible :
| Méthode | Contexte cible | RFC | UX |
|---|---|---|---|
| A — Token cached (no-prompt) | Toute exécution après une 1ʳᵉ connexion réussie | — | Zéro friction |
| B — Device Flow | CLI headless / SSH / container / mobile / IoT / pas de browser local | 8628 | User ouvre browser sur autre device |
| C — PKCE Localhost | Desktop CLI (macOS/Linux/Win avec browser) | 8252 + 7636 | Browser auto-ouvert + redirect localhost |
| D — Custom store / mobile / serverless | App mobile, lambda, container immutable (pas de FS write) | — | Override AuthStore DI |
A — Token cached (le cas nominal après 1ʳᵉ connexion)
flow.run() est idempotent : il commence par store.load(), et si un token valide (non-expiré) y est trouvé, il est retourné immédiatement, sans aucun prompt.
import { createDeviceFlow, createFsAuthStore } from '@mostajs/auth-flow/client'
const flow = createDeviceFlow({
host: 'octonet',
authorizeEndpoint: 'https://octonet.cloud/api/v1/auth/device/authorize',
tokenEndpoint: 'https://octonet.cloud/api/v1/auth/device/token',
clientId: 'data-plug-cli',
scope: ['octonet.read'],
onCodeIssued: () => { /* jamais appelé si token cached */ },
store: createFsAuthStore(),
})
const token = await flow.run()
// 1ʳᵉ exécution : déclenche le device flow, persiste, retourne le token.
// 2ᵉ exécution (jours plus tard) : lit ~/.config/octonet/auth.json, retourne directement.Forcer un re-prompt (utile pour rotation manuelle) :
await flow.signOut() // efface le fichier
const token = await flow.run() // déclenche le flow complet
// — ou de manière équivalente :
const token = await flow.refresh()B — Device Flow (RFC 8628) — SDK headless
Choix par défaut. Marche partout : SSH session, conteneur Docker, machine sans browser, mobile, IoT, CI/CD à approbation manuelle.
B.1 — Wire-up minimal côté CLI
import { createDeviceFlow, createFsAuthStore } from '@mostajs/auth-flow/client'
const flow = createDeviceFlow({
host: 'octonet',
authorizeEndpoint: 'https://octonet.cloud/api/v1/auth/device/authorize',
tokenEndpoint: 'https://octonet.cloud/api/v1/auth/device/token',
clientId: 'data-plug-cli',
scope: ['octonet.read', 'octonet.storage.upload'],
onCodeIssued: ({ user_code, verification_uri, verification_uri_complete, expires_in }) => {
// Affichage CLI minimal — UX "Claude Code style"
console.log('')
console.log(` Open the following URL in your browser:`)
console.log(` ${verification_uri}`)
console.log('')
console.log(` Enter the code: ${user_code}`)
console.log('')
console.log(` (Or scan: ${verification_uri_complete ?? '<not provided>'})`)
console.log(` Code expires in ${Math.round(expires_in / 60)} min...`)
console.log('')
},
onSlowDown: () => console.warn('[auth] server asked us to slow polling'),
store: createFsAuthStore(),
})
try {
const token = await flow.run()
console.log(`✓ Authenticated.`)
// Utiliser token.access_token comme apikey dans les requêtes ultérieures
} catch (err) {
console.error(`✗ Authentication failed:`, err)
process.exit(1)
}B.2 — Variantes d'affichage du user_code
| Contexte | UX recommandée |
|---|---|
| Terminal interactif | console.log avec couleurs ANSI + verification_uri cliquable |
| GUI native (Electron) | dialog.showMessageBox({ message: user_code }) |
| Mobile native | <QRCode value={verification_uri_complete} /> + <Text>{user_code}</Text> |
| Web embed | popup léger ou banner de la web app |
| Tests automatisés | onCodeIssued: (info) => mockApprove(info.user_code) |
B.3 — Enchaînement avec data-plug
C'est le cas d'usage canonique côté Octonet :
// data-plug/lib/auto-register.ts (étape 3 du plan @mostajs/auth-flow)
import { createDeviceFlow, createFsAuthStore } from '@mostajs/auth-flow/client'
export async function autoRegisterApiKey(): Promise<string> {
const cached = await loadCached()
if (cached?.access_token) return cached.access_token
const flow = createDeviceFlow({
host: 'octonet',
authorizeEndpoint: process.env.MOSTA_NET_URL + '/api/v1/auth/device/authorize',
tokenEndpoint: process.env.MOSTA_NET_URL + '/api/v1/auth/device/token',
clientId: 'data-plug-cli',
scope: ['data-plug.full'],
onCodeIssued: defaultTerminalRenderer,
store: createFsAuthStore({ host: 'octonet' }),
})
const token = await flow.run()
return token.access_token
}C — PKCE Localhost (RFC 8252) — Desktop CLI
Activable comme alternative à B sur les machines de bureau capables d'ouvrir un browser ET d'écouter sur localhost. UX la plus fluide : zéro copier-coller du code.
import { createPkceFlow, createFsAuthStore } from '@mostajs/auth-flow/client'
const flow = createPkceFlow({
host: 'octonet',
authorizeEndpoint: 'https://octonet.cloud/api/v1/oauth/authorize',
tokenEndpoint: 'https://octonet.cloud/api/v1/oauth/token',
clientId: 'data-plug-desktop',
scope: ['octonet.read'],
callbackPort: 0, // 0 = port dynamique (recommandé)
callbackPath: '/callback',
timeoutMs: 300_000, // 5 min max d'attente du clic browser
store: createFsAuthStore(),
})
// Sous le capot :
// 1. Génère code_verifier + state via @mostajs/auth/lib/oauth-primitives
// 2. Démarre HTTP listener sur localhost:<port>
// 3. Ouvre browser sur authorizeEndpoint avec ?redirect_uri=http://localhost:<port>/callback
// 4. Browser → user login → redirect → callback reçoit le code
// 5. POST tokenEndpoint avec code + code_verifier
// 6. Persiste via store
const token = await flow.run()Quand préférer PKCE à Device Flow :
- L'user est déjà devant un browser ouvert
- Tu veux supprimer le copier-coller du
user_code
Quand revenir à Device Flow :
- SSH session (pas de browser local)
- Container / serveur sans display
- Le port localhost est bloqué par firewall
- Tu n'as pas de manière de spawn
open/xdg-open
C.1 — Fallback automatique conseillé
async function login(): Promise<string> {
const config = { /* commun */ }
try {
const flow = createPkceFlow(config)
return (await flow.run()).access_token
} catch (err) {
if (err instanceof Error && err.message.includes('no_browser')) {
console.log('No browser available — falling back to device flow')
const flow = createDeviceFlow(config)
return (await flow.run()).access_token
}
throw err
}
}D — Override AuthStore (mobile / serverless / SecretManager)
Le default createFsAuthStore() écrit dans ~/.config/<host>/auth.json mode 0600. Inutilisable dans :
- Lambda / Cloud Run (FS read-only ou éphémère)
- Containers immutables (FS reset à chaque restart)
- Mobile native (storage securisé OS-spécifique : Keychain iOS, KeyStore Android)
D.1 — Mobile : iOS Keychain via Capacitor
import { SecureStoragePlugin } from 'capacitor-secure-storage-plugin'
import { createDeviceFlow, type AuthStore, type AuthStoreRecord } from '@mostajs/auth-flow/client'
const keychainStore: AuthStore = {
async load() {
try {
const v = await SecureStoragePlugin.get({ key: 'octonet:auth' })
return JSON.parse(v.value) as AuthStoreRecord
} catch { return null }
},
async save(record) {
await SecureStoragePlugin.set({ key: 'octonet:auth', value: JSON.stringify(record) })
},
async clear() {
await SecureStoragePlugin.remove({ key: 'octonet:auth' })
},
}
const flow = createDeviceFlow({ /* ... */, store: keychainStore })D.2 — Lambda : SecretManager AWS
import { SecretsManagerClient, GetSecretValueCommand, UpdateSecretCommand, DeleteSecretCommand } from '@aws-sdk/client-secrets-manager'
const sm = new SecretsManagerClient({})
const secretId = `octonet/auth/${process.env.LAMBDA_FUNCTION_NAME}`
const lambdaStore: AuthStore = {
async load() {
try {
const r = await sm.send(new GetSecretValueCommand({ SecretId: secretId }))
return JSON.parse(r.SecretString!) as AuthStoreRecord
} catch (e: any) {
if (e.name === 'ResourceNotFoundException') return null
throw e
}
},
async save(record) {
await sm.send(new UpdateSecretCommand({ SecretId: secretId, SecretString: JSON.stringify(record) }))
},
async clear() {
await sm.send(new DeleteSecretCommand({ SecretId: secretId, ForceDeleteWithoutRecovery: true }))
},
}D.3 — Tests : in-memory
import { createMemoryAuthStore } from '@mostajs/auth-flow/client'
const flow = createDeviceFlow({ /* ... */, store: createMemoryAuthStore() })Côté serveur — wire-up détaillé par framework
Next.js App Router (Octonet typique)
// app/api/v1/auth/device/authorize/route.ts
import { handlers } from '@/lib/auth-flow-handlers'
export const POST = (req: Request) => handlers.authorize(req)
// app/api/v1/auth/device/token/route.ts
export const POST = (req: Request) => handlers.token(req)
// app/api/v1/auth/device/approve/route.ts
export const POST = (req: Request) => handlers.approve(req)
// app/device/page.tsx — délégué OU vanilla
export const dynamic = 'force-dynamic'
export default async function DevicePage({ searchParams }: { searchParams: { user_code?: string } }) {
// Soit on délègue à handlers.devicePage (vanilla page)
// Soit on rend notre propre page React :
return <CustomDeviceConsentPage initialCode={searchParams.user_code} />
}Fastify
import fastify from 'fastify'
import { handlers } from './auth-flow-handlers.js'
const app = fastify()
const wrap = (h: (r: Request) => Promise<Response>) => async (req: any, reply: any) => {
const url = `${req.protocol}://${req.hostname}${req.url}`
const headers = new Headers(req.headers)
const body = req.body ? JSON.stringify(req.body) : undefined
const webReq = new Request(url, { method: req.method, headers, body })
const webResp = await h(webReq)
reply.code(webResp.status)
webResp.headers.forEach((v, k) => reply.header(k, v))
reply.send(await webResp.text())
}
app.post('/api/v1/auth/device/authorize', wrap(handlers.authorize))
app.post('/api/v1/auth/device/token', wrap(handlers.token))
app.get ('/device', wrap(handlers.devicePage))
app.post('/api/v1/auth/device/approve', wrap(handlers.approve))Express
Idem Fastify avec body-parser puis adapter req → Request. Le module ne ship pas l'adaptateur — pattern bien connu (@vercel/node-like).
Branchement DI complet (Octonet de référence)
// lib/auth-flow-handlers.ts
import { createDeviceFlowHandlers, createOrmPendingStore } from '@mostajs/auth-flow/server'
import { getApiKeyService } from '@mostajs/api-keys'
import { auth } from '@/auth' // NextAuth via @mostajs/auth
import { redirectToCheckout } from '@/lib/payment' // @mostajs/payment
export const handlers = createDeviceFlowHandlers({
baseUrl: process.env.PUBLIC_BASE_URL!,
pendingStore: createOrmPendingStore({ orm: globalOrm }),
// R2 — token via @mostajs/api-keys
issueAccessToken: async ({ accountId, scopes, clientId, pendingId }) => {
const apiKeys = await getApiKeyService()
const generated = await apiKeys.create({
accountId,
label: `auth-flow:${clientId}`,
permissions: {
scopes: scopes.reduce((m, s) => ({ ...m, [s]: '*' as const }), {}),
metadata: { issuedVia: 'device-flow', clientId, pendingId },
},
expiresAt: new Date(Date.now() + 365 * 86400_000),
})
return {
access_token: generated.full,
token_type: 'Bearer',
expires_in: 365 * 86400,
scope: scopes.join(' '),
}
},
// R2 — session via @mostajs/auth
resolveUserSession: async (req) => {
const session = await auth(req)
return session?.user?.accountId ? { accountId: session.user.accountId } : null
},
// R5 — anonymous → trial gratuit OU paid via @mostajs/payment
onAnonymousVisitor: async ({ pendingId, req }) => {
const url = new URL(req.url)
const intent = url.searchParams.get('plan') // 'free' ou 'paid'
if (intent === 'paid') {
// Stripe checkout via @mostajs/payment
const checkoutUrl = await redirectToCheckout({ planId: 'pro-yearly', successPath: `/device?continue=${pendingId}` })
return Response.redirect(checkoutUrl)
}
// Trial gratuit : sign-up direct + retour /device
return Response.redirect(`${process.env.PUBLIC_BASE_URL}/signup?next=/device&pid=${pendingId}`)
},
expiresInSec: 1800,
intervalSec: 5,
defaultPage: 'vanilla',
})Spec mappée sur le module
Endpoints (RFC 8628)
| Méthode | Path | RFC § | Module |
|---|---|---|---|
| POST | /device/authorize | §3.1 | handlers.authorize |
| POST | /device/token | §3.4 | handlers.token |
| GET | /device (page) | §3.2 (UX) | handlers.devicePage |
| POST | /device/approve (interne, post-consent) | §3.3 | handlers.approve |
Endpoints PKCE (RFC 8252 + RFC 7636)
| Méthode | Path | RFC § | Module |
|---|---|---|---|
| GET | /oauth/authorize | RFC 6749 §4.1.1 | handlers.pkceAuthorize |
| POST | /oauth/token | RFC 6749 §4.1.3 + RFC 7636 §4.5 | handlers.pkceToken |
Réponses normalisées
POST /device/authorize :
{
"device_code": "GmRhmhcxhwAzkoEqiMEg_DnyEysNkuNhszIySk9eS",
"user_code": "WDJB-MJHT",
"verification_uri": "https://octonet.cloud/device",
"verification_uri_complete": "https://octonet.cloud/device?user_code=WDJB-MJHT",
"expires_in": 1800,
"interval": 5
}POST /device/token :
- En attente :
{ "error": "authorization_pending" }(RFC 8628 §3.5) - Trop fréquent :
{ "error": "slow_down" }(le client doit augmenterinterval) - Approuvé :
{ "access_token": "...", "expires_in": 31536000, "token_type": "Bearer" }
Sécurité (R5 du plan)
| Vecteur | Mitigation |
|---|---|
| Brute-force user_code (8 chars) | Rate-limit côté /device/approve : max 5 tentatives/min/IP. Codes incluent un dash (WDJB-MJHT) pour réduire mistypes mais sans entropie supplémentaire. |
| Replay device_code après consume | Marqué consumed_at à l'approbation ; tout polling ultérieur retourne expired_token. |
| Phishing (faux site /device) | Le verification_uri doit être HTTPS + same-origin que tokenEndpoint. Documenté dans le warning au lancement CLI. |
| Time-of-check (race) | pendingStore.consumeAtomic(deviceCode) garantit l'atomicité émetteur unique. |
| TTL trop long | Default expires_in: 1800 (30 min). Le polling renvoie expired_token après. Configurable au boot. |
| PKCE downgrade | Le serveur exige code_challenge_method=S256 ; rejette plain (RFC 7636 §4.2). |
| State CSRF (PKCE) | state paramètre obligatoire, vérifié côté serveur. |
Persistence côté client
Format ~/.config/<host>/auth.json, mode 0600 :
{
"version": 1,
"host": "octonet",
"endpoint": "https://octonet.cloud",
"token": {
"access_token": "ok_xxxxxxxxxxxx",
"token_type": "Bearer",
"expires_at": 1789234567,
"scope": "octonet.read"
},
"issued_at": 1757234567,
"client_id": "data-plug-cli"
}Override via AuthStore DI pour serverless / containers immutables :
const flow = createDeviceFlow({
...
store: {
async load() { return await secureStorage.get('octonet:token') },
async save(record) { await secureStorage.set('octonet:token', record) },
async clear() { await secureStorage.delete('octonet:token') },
}
})Hors-périmètre v0.1 (décisions explicites — pas du vapor)
- Refresh tokens — RFC 6749 §6 — postposé v0.2 si demande customer (la plupart des SDKs refont un device flow à expiration).
- OIDC discovery (
.well-known/openid-configuration) — postposé v0.2. - Dynamic client registration (RFC 7591) — out-of-scope, le
clientIdest convenu hors-bande pour v1.x. - Federated identity (Google login, etc., sur la page
/device) — c'est au host de la câbler dansonAnonymousVisitor. - Audit log auth-flow — postposé v0.2 ; le host peut logguer via
@mostajs/auditdans ses DI callbacks.
Modules amis (DI typique)
| Pour... | DI | Branchement Octonet |
|---|---|---|
| Émettre l'access_token | issueAccessToken({...}) | @mostajs/api-keys.create() |
| Identifier l'user sur /device | resolveUserSession(req) | @mostajs/auth (NextAuth session) |
| Free signup → trial / paid → checkout | onAnonymousVisitor({...}) | @mostajs/payment (Stripe / Chargily / Satim) |
| Persister les device_code pending | pendingStore | @mostajs/orm (table auth_pending) |
| Logger les events | (à la main dans les DI) | @mostajs/audit |
Plan d'implémentation
Voir docs/PLAN.md — étapes 1-4, ~5-6 jours dev senior pour atteindre la v0.1.0 livrable.
Release Notes
v0.1.0-alpha.3 — 2026-05-02 — Étape 2 PKCE (Session N+3) : pkce-flow.ts + pkce-handlers.ts + tests T3
Étape 2 du plan complétée — le module supporte maintenant les deux RFC en parallèle :
- RFC 8628 (Device Flow) — alpha.1 client + alpha.2 server
- RFC 8252 + 7636 (PKCE Authorization Code Flow) — alpha.3 client + server
Livré côté CLIENT — src/client/pkce-flow.ts (~280 lignes)
createPkceFlow():run()+refresh()+signOut()- PKCE primitives inlined (zéro dep) :
generateCodeVerifier(48 bytes base64url) +deriveCodeChallenge(SHA-256 base64url) +generateState(24 bytes) - Listener localhost sur port dynamique (
callbackPort: 0) ou spécifié —node:httpéphémère, écoute sur 127.0.0.1 uniquement - Browser open par défaut (
openmacOS /xdg-openLinux /startWin) ; override-able viaopenBrowserDI ; fallback : print URL si exec échoue - Anti-CSRF state mismatch : reject avec
code: 'state_mismatch' - Timeout configurable (default 5 min) — listener clean shutdown si user n'ouvre pas
- Token cached short-circuit identique au device flow (60s skew)
- Page de succès HTML simple servie au browser après callback (UX "tu peux fermer cette fenêtre")
Livré côté SERVER — src/server/pkce-handlers.ts (~210 lignes)
createPkceHandlers():pkceAuthorize+pkceTokenpkceAuthorize:GET /oauth/authorize— RFC 6749 §4.1.1 + RFC 7636 §4code_challenge_method=S256obligatoire —plainrejeté (RFC 7636 §4.2 + §7.2)- State CSRF obligatoire ≥ 8 chars (RFC 6749 §10.12)
redirect_uriwhitelist via DIvalidateRedirectUri({clientId, redirectUri})- User logué → consent implicite →
pendingStore.insert(status='approved')→ 302 redirect aveccode+state - User anonymous →
onAnonymousVisitor({pendingId, flow: 'pkce'})(host gère signup/Stripe)
pkceToken:POST /oauth/token— RFC 6749 §4.1.3 + RFC 7636 §4.5/4.6- Vérifie
grant_type='authorization_code',code,code_verifier,redirect_uri,client_id - PKCE verify :
expected = SHA256(code_verifier), comparaisontimingSafeEqual→ si mismatch retourneinvalid_request_pkce(= bad_verifier côté audit, attaque potentielle) - Atomicité :
consumeAtomicaprès verify OK → impossible de réutiliser le code issueAccessToken(DI) →TokenResponse
- Vérifie
Tests T3 — 54 nouvelles assertions / ~3 s
test-scripts/test-pkce-handlers.ts (32 assertions, T3.1.A → T3.1.J)
- Flow nominal user logué → 302 redirect avec
code+state, pending status=approvedaccountId - Refus
code_challenge_method=plain→invalid_request_pkce(RFC 7636 §4.2) - Refus
statemanquant ou trop court →invalid_request - Refus
redirect_urinon whitelistée (DIvalidateRedirectUriretournefalse) →invalid_request - Anonymous →
onAnonymousVisitorinvoqué avecflow=pkce+pendingId pkceTokenflow nominalverifiermatchchallenge→access_tokenissued via DI- Replay
codeaprès consume →invalid_grant(anti double-issue) - Bad verifier (mauvais
code_verifier) →invalid_request_pkce - Mismatch
redirect_uriouclient_id→invalid_grant grant_typeincorrect ou champs manquants →unsupported_grant_type/invalid_request- Refus
response_type != code
test-scripts/test-pkce-client.ts (22 assertions, T3.2.A → T3.2.G)
- Flow nominal end-to-end : authorize URL bien formée (RFC params) + browser stub → callback localhost → exchange POST →
access_tokenpersisté - Token cached non-expiré → zéro HTTP, zéro browser
- State mismatch (CSRF simulation) → throw
state_mismatch refresh()force browser malgré cachedsignOut()vide le store- Token endpoint
error→ throw aveccodepropagé (invalid_request_pkce, etc.) - Callback
?error=access_denied→ throwoauth_error
Suite agrégée auth-flow : 132 → 186 ✅
Bump 0.1.0-alpha.2 → 0.1.0-alpha.3.
Étape 2 close — Étape 3+ (cf. roadmap §3 N+4 et N+5)
@mostajs/auth-flow v0.1.0-alpha.3 est fonctionnellement complet côté module (device flow + PKCE, client + server, page vanilla). Reste pour atteindre 0.1.0 release stable :
- Session N+4 :
data-plug/lib/auto-register.ts+ Octonet routes Next.js + page/deviceoctonet-cloud - Session N+5 :
onAnonymousVisitorbranchement trial / Stripe via@mostajs/payment+ tests E2E (Playwright)
v0.1.0-alpha.2 — 2026-05-02 — Étape 2 server (Session N+2) : pending-store + device-handlers + page vanilla
Deuxième moitié de l'Étape 2 du plan @mostajs/auth-flow (cf. roadmap §3 Session N+2 (b)). Implémente le device flow RFC 8628 côté serveur + page HTML vanilla.
Livré côté SERVER
src/server/pending-store.ts (~150 lignes)
createMemoryPendingStore(): Map + index multi-clés (deviceCode/userCode/state/id) + cleanup TTL viadeleteExpired()createOrmPendingStore({ orm, entityName? }): adapté@mostajs/ormviarepository(). Atomicité viaupdateManyconditionnés (WHERE status=X AND id=Y) — pattern standard SQL/Mongo, garantit single-emission même multi-instance.approveAtomic({id, accountId}):pending→approved(oufalsesi déjà non-pending / expiré) — flipstatus='expired'au passage si TTL dépassé.consumeAtomic(id):approved→consumedavecconsumedAt=now(one-shot).
src/server/device-handlers.ts (~250 lignes)
createDeviceFlowHandlers():authorize/token/devicePage/approve.authorize:POSTRFC 8628 §3.1 — génèredevice_code(32 bytes base64url) +user_code(8 chars alphabet sans ambiguïté no-O/0 no-I/1 formatXXXX-XXXX), insert pending, retourneDeviceCodeResponsecomplet.token:POSTRFC 8628 §3.4 — polling, gèregrant_type, mismatchclient_id,pending/approved/denied/expired/consumed.consumeAtomicatomique →issueAccessToken(DI) →TokenResponse.devicePage:GET— sert HTML vanilla par défaut,pageRendererDI override-able,'none'si le host sert sa propre page.approve:POSTavec brute-force tracker (5 attempts/min/IP →429) ; user anonymous → délégué àonAnonymousVisitorDI (host gère signup/Stripe via@mostajs/payment).- Body parsing tolérant :
application/x-www-form-urlencodedETapplication/json. - Anti-enumeration :
user_codeinconnu retourneinvalid_grantgénérique (pas 404).
src/web/device-page.ts (~110 lignes)
renderDevicePage(props): HTML vanilla zero-dep, ~3kb- Branding configurable (
serviceName,logoUrl,primaryColor) securityNoticepar défaut "Vérifie que cette URL correspond à ton service"- Form
approve/denyviafetch POST x-www-form-urlencoded - Anti-XSS :
escapeHtmlsur tous les inputs templated
src/server/index.ts (modifié) : remplace les declare function placeholders par des re-exports export … from './device-handlers.js' / './pending-store.js'.
src/web/index.ts (modifié) : idem pour renderDevicePage.
Tests T2 — 74 nouvelles assertions / ~5 s
test-scripts/test-pending-store.ts (30 assertions, T2.1.A → T2.1.H)
- Lookup multi-clés (
deviceCode/userCode/state/id) approveAtomicsingle-shot, refus si non-pending ou expiréconsumeAtomicflipapproved→consumed, refus replay (one-shot)- Race simulation : 2
consumeAtomicconcurrents → exactement 1 réussi deleteExpiredclean les records dépassés + nettoie les indexesidcustom respecté à l'insert
test-scripts/test-device-handlers.ts (44 assertions, T2.2.A → T2.2.M)
authorize→DeviceCodeResponsecomplet,user_codeXXXX-XXXX, pending insert- Refus
client_idmanquant →invalid_request 400 token pending→authorization_pending- Flow nominal
authorize→approve→token→access_tokenissued via DI - Replay token endpoint après consume →
expired_token(anti double-issue) grant_typeincorrect →unsupported_grant_type- Mismatch
client_id→invalid_grant approveuser anonymous →onAnonymousVisitorinvoqué (host signup/Stripe)- Anti-enumeration :
user_codeinconnu →invalid_grantgénérique - Brute-force protection : 6ᵉ tentative wrong →
429 GET /device: HTML vanilla servi aveccontent-type: text/html,user_codepré-remplidefaultPage=none→404(host gère sa propre page)pageRendererDI override- Body
authorizeen JSON aussi accepté
Suite agrégée auth-flow : 58 → 132 ✅
Bump 0.1.0-alpha.1 → 0.1.0-alpha.2.
Reste à faire pour clore Étape 2 (Session N+3 du roadmap)
src/client/pkce-flow.ts(RFC 8252 + 7636) avec listener localhostsrc/server/pkce-handlers.ts(PKCE verify SHA-256 + state CSRF)- Tests T3 PKCE end-to-end
v0.1.0-alpha.1 — 2026-05-02 — Étape 2 client (Session N+1) : device-flow.ts + auth-store.ts
Première moitié de l'Étape 2 du plan @mostajs/auth-flow (cf. docs/PLAN.md + Entreprise/Octonet-as-Supabase/11-AUTOREGISTER-FLOW-ROADMAP.md §3.1) — implémentation du flow client RFC 8628 + persistance locale du token.
Livré côté CLIENT
src/client/auth-store.ts (~140 lignes)
createFsAuthStore()XDG-compliant :- Linux/macOS =
$XDG_CONFIG_HOME/<host>/auth.json(fallback~/.config/<host>/auth.json) - Windows =
%APPDATA%/<host>/auth.json
- Linux/macOS =
- Atomic write :
.tmp.<random>+rename— pas de fichier partiel si crash en pleine écriture - Mode
0600sur le fichier +0700sur le dir parent (mkdir recursive) sanitizeHostName: neutralise les caractères filesystem (anti path-traversal), lowercase, max 64 chars, fallback'mostajs'si vide- Tolère JSON corrompu et version inconnue (retourne
nullsans throw — caller refera le flow) createMemoryAuthStore()pour les tests +reset()helperresolveAuthFilePath()exporté pour les tests + outils tiers
src/client/device-flow.ts (~160 lignes)
createDeviceFlow():run()+refresh()+signOut()run()commence parstore.load()— si token cached non-expiré (60s skew) → return direct, short-circuit zéro HTTP. Reconstruitexpires_in = remaining secondspour cohérence caller.- Sinon flow complet :
POST /authorize→onCodeIssued(code, uri)→ polling/tokenavec backoff respectinterval+slow_down(+5spar occurrence + appelonSlowDownsi fourni) - Erreurs RFC 8628 §3.5 traitées :
authorization_pending→ continuer pollingslow_down→ augmenter intervalaccess_denied→ throw aveccode: 'access_denied'expired_token→ throw aveccode: 'expired_token'
- À l'approbation :
store.save(record)avecexpires_atcalculé en epoch ms - Body
/authorize:application/x-www-form-urlencoded { client_id, scope } - Body
/token:grant_type=urn:ietf:params:oauth:grant-type:device_code+device_code+client_id maxPollscap configurable (default =expires_in/interval + 5)
src/client/index.ts (modifié)
- Remplace les
declare functionplaceholders alpha.0 par des re-exportsexport … from './device-flow.js' / './auth-store.js' - Surface API publique identique au squelette alpha.0 — pas de breaking change
Tests T1 — 58 assertions / ~20 s
test-scripts/test-auth-store.ts (27 assertions, T1.1.A → T1.1.G)
sanitizeHostName: path traversal neutralisé, slashes filtrés, empty → fallback, troncature 64 chars, allowlista-z0-9._-resolveAuthFilePath:XDG_CONFIG_HOMEhonoré, fallback~/.config,APPDATAWindows- Round-trip
save/load+ mode0600+ dir0700,.tmporphelin absent post-rename clear()supprime + idempotentENOENT- JSON corrompu et version inconnue →
nulltolérant - Memory store +
resethelper - Default
host = 'mostajs'quand non fourni
test-scripts/test-device-flow-client.ts (31 assertions, T1.2.A → T1.2.J)
- Flow nominal :
authorize→ pollingpending→token→store.saveinvoqué - Cached token non-expiré : zéro HTTP fait
- Cached expiré : flow complet redéclenché
slow_down: interval+5sentre 1ʳᵉ et 2ᵉ poll (mesuré ~7s, attendu 6.5-14s) +onSlowDownaccess_denied: throw avec code, rien persistéexpired_token: throw avec coderefresh()force flow même avec cachedsignOut()vide le store- Body
/authorizebien formé (POST+client_id+scopejoinable) - Body
/tokenutilisegrant_typeRFC 8628 +device_code+client_id
test-scripts/run-tests.sh : runner agrégé adapté de mosta-auth (parse Resultats: X passed, Y failed).
Build : npx tsc --noEmit exit 0 + npm run build regénère dist/{client,server,types,web}/*.{js,d.ts} + fix-esm OK.
Reste à faire pour clore Étape 2
src/client/pkce-flow.ts(RFC 8252 + 7636) — Session N+3src/server/{device-handlers,pending-store,pkce-handlers}.ts— Session N+2src/web/device-page.ts(HTML vanilla) — Session N+3- Mini-PR
@mostajs/authv3.0.1 : 10 nouveauxAuthEventKinddevice_flow.*+pkce.*(à faire en tête Session N+2, cf. roadmap §1 décision 9 + §3 phasing)
v0.1.0-alpha.0 — 2026-05-01 — Étape 1 : squelette + interfaces complètes
Premier commit du module @mostajs/auth-flow (cf. plan dans docs/PLAN.md).
Couvre l'Étape 1 du plan en 4 points :
package.json(alpha v0.1.0-alpha.0) +tsconfig.json+LICENSE+.gitignoreREADME.mdcomplet avec spec RFC 8628 (Device Flow) + RFC 8252 (PKCE) + RFC 7636- Architecture avec découplage strict (zéro deps obligatoires)
- 2 diagrammes de séquence ASCII (Device Flow + PKCE Flow)
- Section "Méthodes de connexion — guide d'utilisation détaillé" avec 4 cas :
- A — Token cached (no-prompt)
- B — Device Flow (CLI headless / SSH / mobile / IoT)
- C — PKCE Localhost (Desktop CLI)
- D — Override
AuthStore(mobile Keychain, Lambda SecretsManager, in-memory tests)
- Section "Côté serveur" avec wire-up Next.js / Fastify / Express + DI complet Octonet
- Spec mappée sur le module (endpoints + réponses RFC-conformes)
- Sécurité : 7 vecteurs traités (brute-force
user_code, replay, phishing, time-of-check, TTL, PKCE downgrade, state CSRF)
docs/PLAN.md: 4 étapes (~5-6 jours dev senior pour atteindre v0.1.0 livrable)- Interfaces TypeScript complètes SANS implémentation :
src/types/index.ts:DeviceCodeResponse,TokenResponse,OAuthErrorCode/Response,PendingRecord,PendingStatus,AuthStoreRecord,FlowKindsrc/client/index.ts:AuthStore,DeviceFlowConfig/DeviceFlow+createDeviceFlow,PkceFlowConfig/PkceFlow+createPkceFlow,createFsAuthStore,createMemoryAuthStoresrc/server/index.ts:IssueAccessToken,ResolveUserSession,OnAnonymousVisitor,PendingStore+consumeAtomic/approveAtomic,DeviceFlowHandlersConfig/Handlers+createDeviceFlowHandlers,PkceHandlersConfig/Handlers+createPkceHandlers,createMemoryPendingStore,createOrmPendingStoresrc/web/index.ts:DevicePageProps,renderDevicePage
Décisions actées en Étape 1 (cf. docs/PLAN.md §contexte) :
- Nom :
@mostajs/auth-flow(pasregister-flow— couvre aussi le renouvellement) - R1 — Pas de dette : Device Flow (RFC 8628) + PKCE (RFC 8252) en parallèle dès v0.1
- R2 — Token via
@mostajs/api-keys(DIissueAccessToken) — chaque module fait son travail - R3 —
~/.config/<host>/auth.jsonXDG-compliant,hostparamétrable au boot (default'mostajs') - R4 — Page
/deviceHTML vanilla zero-dep + override-able (pageRendererDI) - R5 — Free vs paid délégué au host via
onAnonymousVisitor(Octonet branche@mostajs/paymentStripe checkout ou signup direct)
Audits préalables ✅ :
@mostajs/[email protected]:metadataaccepteclientId+issuedVia+pendingId@mostajs/[email protected]: multi-provider (Stripe/Satim/CIB/Chargily/PayPal) prêt@mostajs/[email protected]+:lib/oauth-primitivesexposegenerateCodeVerifier,deriveCodeChallenge,generateState— auth-flow l'importera à l'Étape 2
Build clean : tsc --noEmit + npm run build sans erreur, dist/{client,server,types,web} générés.
License
AGPL-3.0-or-later + commercial — [email protected].
