mirror of
https://github.com/nestriness/nestri.git
synced 2026-09-19 09:15:19 +03:00
feat: Sync to OSS repo
This commit is contained in:
130
alchemy.run.ts
Normal file
130
alchemy.run.ts
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
import * as Alchemy from 'alchemy';
|
||||||
|
import { adopt } from 'alchemy/AdoptPolicy';
|
||||||
|
import * as Cloudflare from 'alchemy/Cloudflare';
|
||||||
|
import { Redacted } from 'effect';
|
||||||
|
import * as Effect from 'effect/Effect';
|
||||||
|
|
||||||
|
const steamApiKey = Redacted.make(process.env.STEAM_API_KEY!);
|
||||||
|
const sshAuthKey = process.env.SSH_AUTH_KEY || 'dev-ssh-auth-key-change-in-prod';
|
||||||
|
const adminSharedSecret =
|
||||||
|
process.env.ADMIN_SHARED_SECRET || 'dev-admin-shared-secret-change-in-prod';
|
||||||
|
|
||||||
|
const AuthStorage = Cloudflare.KV.Namespace('auth-storage');
|
||||||
|
|
||||||
|
const Database = Effect.gen(function* () {
|
||||||
|
const { stage } = yield* Alchemy.Stack;
|
||||||
|
const database = stage === 'production' ? 'defaultdb' : 'sandbox';
|
||||||
|
return yield* Cloudflare.Hyperdrive.Connection('db', {
|
||||||
|
origin: {
|
||||||
|
scheme: 'postgres',
|
||||||
|
host: 'public-nestri-pg-1-atdogthbymao.db.upclouddatabases.com',
|
||||||
|
port: 11569,
|
||||||
|
database,
|
||||||
|
user: 'upadmin',
|
||||||
|
password: Redacted.make(process.env.DATABASE_PASSWORD!)
|
||||||
|
},
|
||||||
|
dev: {
|
||||||
|
scheme: 'postgres',
|
||||||
|
host: 'localhost',
|
||||||
|
port: 5432,
|
||||||
|
database: 'nestri',
|
||||||
|
user: 'postgres',
|
||||||
|
password: Redacted.make('postgres')
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
export const Auth = Effect.gen(function* () {
|
||||||
|
const { stage } = yield* Alchemy.Stack;
|
||||||
|
const isPermanent = ['production', 'sandbox', 'dev'].includes(stage);
|
||||||
|
return yield* Cloudflare.Worker('auth', {
|
||||||
|
main: 'apps/auth/src/index.ts',
|
||||||
|
compatibility: { flags: ['nodejs_compat'] },
|
||||||
|
env: {
|
||||||
|
AuthStorage,
|
||||||
|
HYPERDRIVE: Database,
|
||||||
|
STEAM_API_KEY: steamApiKey,
|
||||||
|
SSH_AUTH_KEY: sshAuthKey
|
||||||
|
},
|
||||||
|
...(isPermanent ? { observability: { enabled: true } } : {})
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
export const Api = Effect.gen(function* () {
|
||||||
|
const { stage } = yield* Alchemy.Stack;
|
||||||
|
const isPermanent = ["production", "sandbox", "dev"].includes(stage);
|
||||||
|
const prefix = stage === "production" ? "" : `${stage}.`;
|
||||||
|
const authDomain = ["production", "sandbox"].includes(stage)
|
||||||
|
? `${prefix}auth.nestri.io`
|
||||||
|
: undefined;
|
||||||
|
return yield* Cloudflare.Worker("api", {
|
||||||
|
main: "apps/api/app/index.ts",
|
||||||
|
compatibility: { flags: ["nodejs_compat"] },
|
||||||
|
env: {
|
||||||
|
AUTH: Auth,
|
||||||
|
AUTH_ISSUER_URL: authDomain
|
||||||
|
? `https://${authDomain}`
|
||||||
|
: "http://localhost:1337",
|
||||||
|
HYPERDRIVE: Database,
|
||||||
|
STEAM_API_KEY: steamApiKey,
|
||||||
|
ADMIN_SHARED_SECRET: adminSharedSecret,
|
||||||
|
},
|
||||||
|
...(isPermanent ? { observability: { enabled: true } } : {}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
export default Alchemy.Stack(
|
||||||
|
'nestri',
|
||||||
|
{
|
||||||
|
providers: Cloudflare.providers(),
|
||||||
|
state: Alchemy.localState()
|
||||||
|
},
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const { stage } = yield* Alchemy.Stack;
|
||||||
|
|
||||||
|
yield* Database;
|
||||||
|
const auth = yield* Auth;
|
||||||
|
const api = yield* Api;
|
||||||
|
|
||||||
|
if (stage === "production" || stage === "sandbox") {
|
||||||
|
const zone = yield* Cloudflare.Zone.Zone("zone", {
|
||||||
|
name: "nestri.io",
|
||||||
|
}).pipe(adopt(true));
|
||||||
|
|
||||||
|
const prefix = stage === "production" ? "" : `${stage}.`;
|
||||||
|
|
||||||
|
yield* Cloudflare.DNS.Record("auth-dns", {
|
||||||
|
zoneId: zone.zoneId,
|
||||||
|
name: `${prefix}auth.nestri.io`,
|
||||||
|
type: "AAAA",
|
||||||
|
content: "100::",
|
||||||
|
proxied: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
yield* Cloudflare.DNS.Record("api-dns", {
|
||||||
|
zoneId: zone.zoneId,
|
||||||
|
name: `${prefix}api.nestri.io`,
|
||||||
|
type: "AAAA",
|
||||||
|
content: "100::",
|
||||||
|
proxied: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
yield* Cloudflare.Workers.WorkerRoute("auth-route", {
|
||||||
|
zoneId: zone.zoneId,
|
||||||
|
pattern: `${prefix}auth.nestri.io/*`,
|
||||||
|
script: auth.workerName,
|
||||||
|
});
|
||||||
|
|
||||||
|
yield* Cloudflare.Workers.WorkerRoute("api-route", {
|
||||||
|
zoneId: zone.zoneId,
|
||||||
|
pattern: `${prefix}api.nestri.io/*`,
|
||||||
|
script: api.workerName,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
authUrl: auth.url.as<string>(),
|
||||||
|
apiUrl: api.url.as<string>()
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
34
apps/api/.gitignore
vendored
Normal file
34
apps/api/.gitignore
vendored
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
# dependencies (bun install)
|
||||||
|
node_modules
|
||||||
|
|
||||||
|
# output
|
||||||
|
out
|
||||||
|
dist
|
||||||
|
*.tgz
|
||||||
|
|
||||||
|
# code coverage
|
||||||
|
coverage
|
||||||
|
*.lcov
|
||||||
|
|
||||||
|
# logs
|
||||||
|
logs
|
||||||
|
_.log
|
||||||
|
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
|
||||||
|
|
||||||
|
# dotenv environment variable files
|
||||||
|
.env
|
||||||
|
.env.development.local
|
||||||
|
.env.test.local
|
||||||
|
.env.production.local
|
||||||
|
.env.local
|
||||||
|
|
||||||
|
# caches
|
||||||
|
.eslintcache
|
||||||
|
.cache
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# IntelliJ based IDEs
|
||||||
|
.idea
|
||||||
|
|
||||||
|
# Finder (MacOS) folder config
|
||||||
|
.DS_Store
|
||||||
107
apps/api/app/index.ts
Normal file
107
apps/api/app/index.ts
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
import type { Api } from '../../../alchemy.run.ts';
|
||||||
|
import type { InferEnv } from 'alchemy/Cloudflare';
|
||||||
|
|
||||||
|
import { Env } from '@nestri/core/env';
|
||||||
|
import { ErrorCodes, VisibleError } from '@nestri/core/error';
|
||||||
|
import { Hono } from 'hono';
|
||||||
|
import { openAPISpecs } from 'hono-openapi';
|
||||||
|
import { cors } from 'hono/cors';
|
||||||
|
import { HTTPException } from 'hono/http-exception';
|
||||||
|
import { logger } from 'hono/logger';
|
||||||
|
import { type ContentfulStatusCode } from 'hono/utils/http-status';
|
||||||
|
|
||||||
|
import { auth } from './middleware/auth.js';
|
||||||
|
import { AccessTokenApi } from './routes/access-token.js';
|
||||||
|
import { GameApi } from './routes/game.js';
|
||||||
|
import { IndexApi } from './routes/index.js';
|
||||||
|
import { LibraryApi } from './routes/library.js';
|
||||||
|
import { MachineApi } from './routes/machine.js';
|
||||||
|
import { PairingCodeApi } from './routes/pairing-code.js';
|
||||||
|
import { SteamApi } from './routes/steam.js';
|
||||||
|
import { UserApi } from './routes/user.js';
|
||||||
|
|
||||||
|
export const app = new Hono();
|
||||||
|
|
||||||
|
app
|
||||||
|
.use(logger())
|
||||||
|
.use(async (c, next) => {
|
||||||
|
c.header('Cache-Control', 'no-store');
|
||||||
|
return next();
|
||||||
|
})
|
||||||
|
.use(
|
||||||
|
cors({
|
||||||
|
origin: () => Env.get().FRONTEND_URL || 'http://localhost:5173',
|
||||||
|
credentials: true
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.use(auth);
|
||||||
|
|
||||||
|
const routes = app
|
||||||
|
.route('/', IndexApi.route)
|
||||||
|
.route('/user', UserApi.route)
|
||||||
|
.route('/steam', SteamApi.route)
|
||||||
|
.route('/library', LibraryApi.route)
|
||||||
|
.route('/games', GameApi.route)
|
||||||
|
.route('/pairing-code', PairingCodeApi.route)
|
||||||
|
.route('/machine', MachineApi.route)
|
||||||
|
.route('/access-token', AccessTokenApi.route)
|
||||||
|
.onError((error, c) => {
|
||||||
|
if (error instanceof VisibleError) {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.error('api error:', error);
|
||||||
|
return c.json(error.toResponse(), error.statusCode() as ContentfulStatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error instanceof HTTPException) {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.error('http error:', error);
|
||||||
|
return c.json(
|
||||||
|
{
|
||||||
|
type: 'validation',
|
||||||
|
code: ErrorCodes.Validation.INVALID_PARAMETER,
|
||||||
|
message: 'Invalid request'
|
||||||
|
},
|
||||||
|
error.status
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.error('unhandled error:', error);
|
||||||
|
return c.json(
|
||||||
|
{
|
||||||
|
type: 'internal',
|
||||||
|
code: ErrorCodes.Server.INTERNAL_ERROR,
|
||||||
|
message: 'Internal server error'
|
||||||
|
},
|
||||||
|
500
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get(
|
||||||
|
'/doc',
|
||||||
|
openAPISpecs(routes, {
|
||||||
|
documentation: {
|
||||||
|
info: {
|
||||||
|
title: 'Nestri API',
|
||||||
|
description: 'API',
|
||||||
|
version: '0.0.1'
|
||||||
|
},
|
||||||
|
components: {
|
||||||
|
securitySchemes: {
|
||||||
|
Bearer: {
|
||||||
|
type: 'http',
|
||||||
|
scheme: 'bearer',
|
||||||
|
bearerFormat: 'JWT'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
security: [{ Bearer: [] }]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
export default {
|
||||||
|
fetch(request: Request, env: InferEnv<typeof Api>, ctx: ExecutionContext) {
|
||||||
|
Env.init(env as unknown as Record<string, unknown>);
|
||||||
|
return app.fetch(request, env, ctx);
|
||||||
|
}
|
||||||
|
};
|
||||||
271
apps/api/app/middleware/auth.ts
Normal file
271
apps/api/app/middleware/auth.ts
Normal file
@@ -0,0 +1,271 @@
|
|||||||
|
import { createClient } from '@nestri/auth/client';
|
||||||
|
import { AccessToken } from '@nestri/core/access-token/index';
|
||||||
|
import { Actor } from '@nestri/core/actor';
|
||||||
|
import { subjects } from '@nestri/core/auth/subjects';
|
||||||
|
import { Env } from '@nestri/core/env';
|
||||||
|
import { ErrorCodes, VisibleError } from '@nestri/core/error';
|
||||||
|
import { Machine } from '@nestri/core/machine/index';
|
||||||
|
import { Member } from '@nestri/core/team/member';
|
||||||
|
import type { MiddlewareHandler } from 'hono';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reaches the auth worker over its service binding.
|
||||||
|
*
|
||||||
|
* The origin has to survive. A binding routes by binding rather than by
|
||||||
|
* hostname, so the host is arbitrary — but `new Request` still demands an
|
||||||
|
* absolute URL, and stripping down to a bare path threw `Invalid URL` before
|
||||||
|
* the token was even looked at.
|
||||||
|
*/
|
||||||
|
function bindingFetch(env: Record<string, unknown>) {
|
||||||
|
return (input: RequestInfo | URL, init?: RequestInit) => {
|
||||||
|
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
|
||||||
|
return (env.AUTH as { fetch: typeof fetch }).fetch(new Request(url, init));
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The issuer must be the auth worker's **public** URL.
|
||||||
|
*
|
||||||
|
* `verify` checks a token's `iss` claim against the issuer the client was
|
||||||
|
* built with, and the auth worker derives what it advertises from the URL it
|
||||||
|
* was reached on. Tokens are minted through the public URL, so they carry it.
|
||||||
|
* A placeholder like `https://auth.internal` addresses the binding perfectly
|
||||||
|
* well — the hostname is ignored there — and then disagrees with every real
|
||||||
|
* token. Discovery through the binding does not help: it answers with the
|
||||||
|
* placeholder too, because that is the host it was asked on.
|
||||||
|
*
|
||||||
|
* The failure is silent by nature. A rejected claim is reported as `err`,
|
||||||
|
* which is indistinguishable from an expired or forged token, so the whole
|
||||||
|
* bearer path returns 401 and looks like ordinary auth working correctly.
|
||||||
|
* Hence the explicit throw rather than a fallback: a misconfiguration here
|
||||||
|
* takes down every user session, and it should say so.
|
||||||
|
*/
|
||||||
|
function getClient(env: Record<string, unknown>) {
|
||||||
|
const configured = Env.get().AUTH_ISSUER_URL;
|
||||||
|
if (!configured) {
|
||||||
|
throw new Error(
|
||||||
|
'AUTH_ISSUER_URL is not configured; every bearer token would be rejected as unsigned'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// The trailing slash matters twice, and both failures are quiet. It is
|
||||||
|
// appended to build the discovery URL, where `…:1337//.well-known/…` is a
|
||||||
|
// 404; and it is compared literally against the `iss` claim, which carries
|
||||||
|
// no trailing slash. A worker URL from the platform arrives with one.
|
||||||
|
const issuer = configured.replace(/\/+$/, '');
|
||||||
|
return createClient({
|
||||||
|
issuer,
|
||||||
|
clientID: 'api',
|
||||||
|
fetch: bindingFetch(env)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export const auth: MiddlewareHandler = async (c, next) => {
|
||||||
|
const adminToken = c.req.header('x-nestri-admin-token');
|
||||||
|
if (adminToken && adminToken === Env.get().ADMIN_SHARED_SECRET) {
|
||||||
|
return Actor.with({ type: 'admin', properties: {} }, next);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A registered nessh host proves it is itself, rather than asserting an id
|
||||||
|
// nobody checks. Wrong credentials fall through to public rather than
|
||||||
|
// erroring, so probing tells an attacker nothing about which ids exist.
|
||||||
|
const machineId = c.req.header('x-nestri-machine-id');
|
||||||
|
const machineSecret = c.req.header('x-nestri-machine-secret');
|
||||||
|
if (machineId && machineSecret) {
|
||||||
|
const machine = await Machine.authenticate({ id: machineId, secret: machineSecret });
|
||||||
|
if (machine) {
|
||||||
|
await Machine.touchLastSeen(machine.id);
|
||||||
|
return Actor.with(
|
||||||
|
{
|
||||||
|
type: 'machine',
|
||||||
|
properties: {
|
||||||
|
machineID: machine.id,
|
||||||
|
ownerUserID: machine.ownerUserId,
|
||||||
|
...(machine.teamId ? { teamID: machine.teamId } : {})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
next
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return Actor.with({ type: 'public', properties: {} }, next);
|
||||||
|
}
|
||||||
|
|
||||||
|
const authHeader = c.req.header('authorization');
|
||||||
|
if (!authHeader) {
|
||||||
|
return Actor.with({ type: 'public', properties: {} }, next);
|
||||||
|
}
|
||||||
|
|
||||||
|
const match = authHeader.match(/^Bearer (.+)$/);
|
||||||
|
if (!match) {
|
||||||
|
return Actor.with({ type: 'public', properties: {} }, next);
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = match[1];
|
||||||
|
|
||||||
|
// A personal access token is resolved from the database, never through JWT
|
||||||
|
// verification. The prefix decides which, so a PAT does not pay for a
|
||||||
|
// well-known lookup and a JWT does not pay for a query.
|
||||||
|
if (AccessToken.looksLikeToken(token)) {
|
||||||
|
const pat = await AccessToken.authenticate(token);
|
||||||
|
if (!pat) {
|
||||||
|
return Actor.with({ type: 'public', properties: {} }, next);
|
||||||
|
}
|
||||||
|
await AccessToken.touchLastUsed(pat.id);
|
||||||
|
|
||||||
|
if (pat.teamId) {
|
||||||
|
// The team grant is re-checked against live membership rather than
|
||||||
|
// trusted from the row, so someone removed from a team loses what
|
||||||
|
// their old token carried without anyone remembering to revoke it.
|
||||||
|
const membership = await Member.findByTeamAndUser({
|
||||||
|
teamId: pat.teamId,
|
||||||
|
userId: pat.ownerUserId
|
||||||
|
});
|
||||||
|
if (!membership) {
|
||||||
|
return Actor.with({ type: 'public', properties: {} }, next);
|
||||||
|
}
|
||||||
|
return Actor.with(
|
||||||
|
{
|
||||||
|
type: 'member',
|
||||||
|
properties: {
|
||||||
|
userID: pat.ownerUserId,
|
||||||
|
role: membership.role,
|
||||||
|
teamID: pat.teamId
|
||||||
|
}
|
||||||
|
},
|
||||||
|
next
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Actor.with(
|
||||||
|
{
|
||||||
|
type: 'user',
|
||||||
|
properties: {
|
||||||
|
userID: pat.ownerUserId,
|
||||||
|
// A PAT is tied to neither a Steam account nor a device, so
|
||||||
|
// it carries neither. A route needing those must read them
|
||||||
|
// from the user rather than assume the caller came by SSH.
|
||||||
|
linkedAccountID: '',
|
||||||
|
fingerprint: undefined
|
||||||
|
}
|
||||||
|
},
|
||||||
|
next
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A token that cannot be verified — malformed, expired, or because the
|
||||||
|
// auth service is unreachable — makes the caller unauthenticated, not the
|
||||||
|
// request a server fault. `verify` reports the first two in `err` and
|
||||||
|
// *throws* the third, and an uncaught throw turned a bad token into a 500.
|
||||||
|
let verified;
|
||||||
|
try {
|
||||||
|
verified = await getClient(c.env).verify(subjects, token);
|
||||||
|
} catch (error) {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.error('token verification failed:', error);
|
||||||
|
return Actor.with({ type: 'public', properties: {} }, next);
|
||||||
|
}
|
||||||
|
if (verified.err) {
|
||||||
|
return Actor.with({ type: 'public', properties: {} }, next);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { subject } = verified;
|
||||||
|
if (subject.type === 'user') {
|
||||||
|
const teamID = c.req.header('x-nestri-team');
|
||||||
|
if (teamID) {
|
||||||
|
const membership = await Member.findByTeamAndUser({
|
||||||
|
teamId: teamID,
|
||||||
|
userId: subject.properties.userID
|
||||||
|
});
|
||||||
|
if (membership) {
|
||||||
|
return Actor.with(
|
||||||
|
{
|
||||||
|
type: 'member',
|
||||||
|
properties: {
|
||||||
|
userID: subject.properties.userID,
|
||||||
|
role: membership.role,
|
||||||
|
teamID
|
||||||
|
}
|
||||||
|
},
|
||||||
|
next
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Actor.with(
|
||||||
|
{
|
||||||
|
type: 'user',
|
||||||
|
properties: {
|
||||||
|
userID: subject.properties.userID,
|
||||||
|
linkedAccountID: subject.properties.linkedAccountID,
|
||||||
|
fingerprint: subject.properties.fingerprint
|
||||||
|
}
|
||||||
|
},
|
||||||
|
next
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Actor.with({ type: 'public', properties: {} }, next);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Requires an authenticated caller of any kind, machines included.
|
||||||
|
*
|
||||||
|
* It deliberately does *not* single machines out: `/games` applies this to the
|
||||||
|
* whole group, and download-state — the one route a box exists to call — sits
|
||||||
|
* inside it. What stops a box from acting as its owner is `Actor.userID`,
|
||||||
|
* which refuses a machine outright, so a route written for a human cannot
|
||||||
|
* silently accept a box no matter which guard it sits behind.
|
||||||
|
*/
|
||||||
|
export const notPublic: MiddlewareHandler = async (_, next) => {
|
||||||
|
const actor = Actor.use();
|
||||||
|
if (actor.type === 'public') {
|
||||||
|
throw new VisibleError(
|
||||||
|
'authentication',
|
||||||
|
ErrorCodes.Authentication.UNAUTHORIZED,
|
||||||
|
'Missing authorization header'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return next();
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Requires credentials belonging to a registered nessh host. */
|
||||||
|
export const machineOnly: MiddlewareHandler = async (_, next) => {
|
||||||
|
const actor = Actor.use();
|
||||||
|
if (actor.type !== 'machine') {
|
||||||
|
throw new VisibleError(
|
||||||
|
'forbidden',
|
||||||
|
ErrorCodes.Permission.INSUFFICIENT_PERMISSIONS,
|
||||||
|
'Machine credentials required'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return next();
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A box reporting about itself, or an operator reaching in.
|
||||||
|
*
|
||||||
|
* The two are not equivalent and routes behind this must not treat them so: a
|
||||||
|
* machine may only speak for itself, while admin still has to say which host
|
||||||
|
* it means. Keeping admin is what lets an operator repair state by hand.
|
||||||
|
*/
|
||||||
|
export const machineOrAdmin: MiddlewareHandler = async (_, next) => {
|
||||||
|
const actor = Actor.use();
|
||||||
|
if (actor.type !== 'machine' && actor.type !== 'admin') {
|
||||||
|
throw new VisibleError(
|
||||||
|
'forbidden',
|
||||||
|
ErrorCodes.Permission.INSUFFICIENT_PERMISSIONS,
|
||||||
|
'Machine or admin credentials required'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return next();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const adminOnly: MiddlewareHandler = async (_, next) => {
|
||||||
|
const actor = Actor.use();
|
||||||
|
if (actor.type !== 'admin') {
|
||||||
|
throw new VisibleError(
|
||||||
|
'forbidden',
|
||||||
|
ErrorCodes.Permission.INSUFFICIENT_PERMISSIONS,
|
||||||
|
'Admin access required'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return next();
|
||||||
|
};
|
||||||
207
apps/api/app/routes/access-token.ts
Normal file
207
apps/api/app/routes/access-token.ts
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
import { AccessToken } from '@nestri/core/access-token/index';
|
||||||
|
import { Actor } from '@nestri/core/actor';
|
||||||
|
import { ErrorCodes, VisibleError } from '@nestri/core/error';
|
||||||
|
import { Examples } from '@nestri/core/examples';
|
||||||
|
import { Identifier } from '@nestri/core/id';
|
||||||
|
import { Member } from '@nestri/core/team/member';
|
||||||
|
import { Hono } from 'hono';
|
||||||
|
import { describeRoute } from 'hono-openapi';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { ErrorResponses, notPublic, Result, validator } from '../utils';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Personal access tokens.
|
||||||
|
*
|
||||||
|
* The credential for anything that is not a browser: a nessh box registering
|
||||||
|
* itself, or a script driving the API. A session JWT cannot do this job — it
|
||||||
|
* is short-lived and cannot be revoked without rotating signing keys for
|
||||||
|
* everyone, which is wrong for something that sits in a config file for
|
||||||
|
* months.
|
||||||
|
*
|
||||||
|
* Minting requires a *user session*, deliberately. Allowing the admin token to
|
||||||
|
* mint one for an arbitrary user would turn a credential that can read and
|
||||||
|
* write all API data into one that can *become* any user, and that boundary is
|
||||||
|
* the reason `Actor.userID` refuses admin at all.
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* Decide what a new token is scoped to.
|
||||||
|
*
|
||||||
|
* Team scope is the default, because a box or a script is nearly always doing
|
||||||
|
* team work and a user-scoped token silently cannot see any of it. But the
|
||||||
|
* default only applies when it is *unambiguous*: with several teams, guessing
|
||||||
|
* would hand out a token reaching resources the caller did not have in mind.
|
||||||
|
*
|
||||||
|
* Note team scope is broader than user scope, never narrower — hence `null` as
|
||||||
|
* an explicit way to ask for the narrow one. It still cannot exceed what the
|
||||||
|
* user themselves may do: the grant is re-checked against live membership on
|
||||||
|
* every request, with their own role.
|
||||||
|
*
|
||||||
|
* @param requested `undefined` to take the default, `null` to force user
|
||||||
|
* scope, or a team id to name one.
|
||||||
|
*/
|
||||||
|
async function resolveTeamScope(requested: string | null | undefined): Promise<string | null> {
|
||||||
|
if (requested === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requested !== undefined) {
|
||||||
|
// Verified here rather than trusted from the body: a token is only ever
|
||||||
|
// as scoped as what was checked at the moment it was made.
|
||||||
|
const membership = await Member.findByTeamAndUser({
|
||||||
|
teamId: requested,
|
||||||
|
userId: Actor.userID
|
||||||
|
});
|
||||||
|
if (!membership) {
|
||||||
|
throw new VisibleError(
|
||||||
|
'forbidden',
|
||||||
|
ErrorCodes.Permission.FORBIDDEN,
|
||||||
|
'You are not a member of that team'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return requested;
|
||||||
|
}
|
||||||
|
|
||||||
|
const memberships = await Member.listByUser(Actor.userID);
|
||||||
|
if (memberships.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (memberships.length > 1) {
|
||||||
|
throw new VisibleError(
|
||||||
|
'validation',
|
||||||
|
ErrorCodes.Validation.INVALID_PARAMETER,
|
||||||
|
'You belong to several teams — name the one this token is for, or pass teamId: null to scope it to yourself',
|
||||||
|
'teamId'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return memberships[0]!.teamId;
|
||||||
|
}
|
||||||
|
|
||||||
|
export namespace AccessTokenApi {
|
||||||
|
export const route = new Hono()
|
||||||
|
.post(
|
||||||
|
'/',
|
||||||
|
notPublic,
|
||||||
|
describeRoute({
|
||||||
|
tags: ['AccessToken'],
|
||||||
|
summary: 'Create a personal access token',
|
||||||
|
description:
|
||||||
|
'Mint a long-lived, revocable token for the calling user. The token is returned once and never again — only its digest is stored.',
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
content: {
|
||||||
|
'application/json': {
|
||||||
|
schema: Result(
|
||||||
|
z.object({
|
||||||
|
id: z.string().meta({ example: Examples.AccessToken.id }),
|
||||||
|
token: z.string().meta({
|
||||||
|
description: 'Shown once. Store it now; it cannot be retrieved.'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
description: 'A freshly minted token'
|
||||||
|
},
|
||||||
|
401: ErrorResponses[401],
|
||||||
|
403: ErrorResponses[403]
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
validator(
|
||||||
|
'json',
|
||||||
|
z.object({
|
||||||
|
name: z.string().min(1).max(64).meta({
|
||||||
|
description: 'What this token is for, so it can be recognised in a list',
|
||||||
|
example: Examples.AccessToken.name
|
||||||
|
}),
|
||||||
|
teamId: z.string().nullable().optional().meta({
|
||||||
|
description:
|
||||||
|
'Team to scope the token to. Omit to default to your team when you have exactly one; pass null to force a token scoped to you alone.'
|
||||||
|
}),
|
||||||
|
expiresInDays: z.number().int().min(1).max(365).optional().meta({
|
||||||
|
description: 'Optional lifetime. Omit for a token that does not expire.'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
),
|
||||||
|
async (c) => {
|
||||||
|
const { name, teamId, expiresInDays } = c.req.valid('json');
|
||||||
|
|
||||||
|
const actor = Actor.use();
|
||||||
|
if (actor.type !== 'user' && actor.type !== 'member') {
|
||||||
|
throw new VisibleError(
|
||||||
|
'forbidden',
|
||||||
|
ErrorCodes.Permission.INSUFFICIENT_PERMISSIONS,
|
||||||
|
'Creating an access token requires a user session'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const scopedTeamId = await resolveTeamScope(teamId);
|
||||||
|
|
||||||
|
const created = await AccessToken.create({
|
||||||
|
id: Identifier.ascending('accessToken'),
|
||||||
|
ownerUserId: Actor.userID,
|
||||||
|
teamId: scopedTeamId,
|
||||||
|
name,
|
||||||
|
expiresInDays
|
||||||
|
});
|
||||||
|
|
||||||
|
return c.json({ data: { id: created.id, token: created.token } });
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.get(
|
||||||
|
'/',
|
||||||
|
notPublic,
|
||||||
|
describeRoute({
|
||||||
|
tags: ['AccessToken'],
|
||||||
|
summary: 'List your access tokens',
|
||||||
|
description: 'Returns metadata only. The token values are not stored and cannot be shown.',
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
content: { 'application/json': { schema: Result(z.array(AccessToken.Info)) } },
|
||||||
|
description: 'Tokens belonging to the caller'
|
||||||
|
},
|
||||||
|
401: ErrorResponses[401],
|
||||||
|
403: ErrorResponses[403]
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
async (c) => {
|
||||||
|
return c.json({ data: await AccessToken.listByOwner(Actor.userID) });
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.delete(
|
||||||
|
'/:id',
|
||||||
|
notPublic,
|
||||||
|
describeRoute({
|
||||||
|
tags: ['AccessToken'],
|
||||||
|
summary: 'Revoke an access token',
|
||||||
|
description:
|
||||||
|
'Revokes immediately. Revocation is the reason these exist rather than long-lived JWTs.',
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
content: { 'application/json': { schema: Result(z.object({ id: z.string() })) } },
|
||||||
|
description: 'The token no longer works'
|
||||||
|
},
|
||||||
|
401: ErrorResponses[401],
|
||||||
|
403: ErrorResponses[403],
|
||||||
|
404: ErrorResponses[404]
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
async (c) => {
|
||||||
|
// Scoped to the owner in the query itself, so revoking someone
|
||||||
|
// else's token is a 404 rather than a permission check that
|
||||||
|
// could be forgotten.
|
||||||
|
const revoked = await AccessToken.revoke({
|
||||||
|
id: c.req.param('id'),
|
||||||
|
ownerUserId: Actor.userID
|
||||||
|
});
|
||||||
|
if (!revoked) {
|
||||||
|
throw new VisibleError(
|
||||||
|
'not_found',
|
||||||
|
ErrorCodes.NotFound.RESOURCE_NOT_FOUND,
|
||||||
|
'No such token, or it is not yours'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return c.json({ data: { id: revoked.id } });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
614
apps/api/app/routes/game.ts
Normal file
614
apps/api/app/routes/game.ts
Normal file
@@ -0,0 +1,614 @@
|
|||||||
|
import { Actor } from '@nestri/core/actor';
|
||||||
|
import { ErrorCodes, VisibleError } from '@nestri/core/error';
|
||||||
|
import { Examples } from '@nestri/core/examples';
|
||||||
|
import { Depot } from '@nestri/core/game/depot';
|
||||||
|
import { GameDownload } from '@nestri/core/game/download';
|
||||||
|
import { GameDownloadStatus } from '@nestri/core/game/download.sql';
|
||||||
|
import { Game } from '@nestri/core/game/index';
|
||||||
|
import { Identifier } from '@nestri/core/id';
|
||||||
|
import { Library } from '@nestri/core/user/library';
|
||||||
|
import { Hono } from 'hono';
|
||||||
|
import { describeRoute } from 'hono-openapi';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { ErrorResponses, adminOnly, machineOrAdmin, notPublic, Result, validator } from '../utils';
|
||||||
|
|
||||||
|
const SyncGameSchema = z.object({
|
||||||
|
steamAppId: z.number().int(),
|
||||||
|
name: z.string(),
|
||||||
|
type: z.string().optional(),
|
||||||
|
clientIcon: z.string().optional(),
|
||||||
|
icon: z.string().optional(),
|
||||||
|
shortDescription: z.string().optional(),
|
||||||
|
description: z.string().optional(),
|
||||||
|
developers: z.array(z.string()).optional(),
|
||||||
|
publishers: z.array(z.string()).optional(),
|
||||||
|
primaryGenre: z.string().optional(),
|
||||||
|
genres: z.array(z.string()).optional(),
|
||||||
|
categories: z.array(z.string()).optional(),
|
||||||
|
oslist: z.array(z.string()).optional(),
|
||||||
|
sizeDownload: z.number().optional(),
|
||||||
|
sizeOnDisk: z.number().optional(),
|
||||||
|
controllerSupport: z.string().optional(),
|
||||||
|
steamDeckCompat: z.string().optional(),
|
||||||
|
reviewScorePercent: z.number().int().optional(),
|
||||||
|
reviewCount: z.number().int().optional(),
|
||||||
|
metacriticScore: z.number().int().optional(),
|
||||||
|
steamChangeNumber: z.number().int().optional(),
|
||||||
|
publicBuildId: z.number().int().optional(),
|
||||||
|
releaseDate: z.string().optional(),
|
||||||
|
enriched: z.boolean().default(false),
|
||||||
|
depots: z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
|
depotId: z.number().int(),
|
||||||
|
branch: z.string().default('public'),
|
||||||
|
steamManifestId: z.string().optional(),
|
||||||
|
steamBuildId: z.number().int().optional(),
|
||||||
|
sizeDownload: z.number().optional(),
|
||||||
|
sizeOnDisk: z.number().optional(),
|
||||||
|
oslist: z.string().optional()
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.optional()
|
||||||
|
});
|
||||||
|
|
||||||
|
const SyncLibrarySchema = z.object({
|
||||||
|
steamAppId: z.number().int(),
|
||||||
|
playtimeForeverMin: z.number().int().optional(),
|
||||||
|
playtime2WeeksMin: z.number().int().optional(),
|
||||||
|
lastPlayed: z.string().optional()
|
||||||
|
});
|
||||||
|
|
||||||
|
export namespace GameApi {
|
||||||
|
export const route = new Hono()
|
||||||
|
.use(notPublic)
|
||||||
|
.get(
|
||||||
|
'/',
|
||||||
|
describeRoute({
|
||||||
|
tags: ['Games'],
|
||||||
|
summary: 'List games',
|
||||||
|
description: 'List all games in the catalog, with optional search',
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
content: {
|
||||||
|
'application/json': {
|
||||||
|
schema: Result(
|
||||||
|
z.array(Game.Info).meta({
|
||||||
|
description: 'All games matching the optional query',
|
||||||
|
example: [Examples.Game]
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
description: 'List of games'
|
||||||
|
},
|
||||||
|
400: ErrorResponses[400],
|
||||||
|
401: ErrorResponses[401]
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
validator(
|
||||||
|
'query',
|
||||||
|
z.object({
|
||||||
|
q: z.string().optional().meta({
|
||||||
|
description: 'Search query to filter games by name',
|
||||||
|
example: 'Counter-Strike'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
),
|
||||||
|
async (c) => {
|
||||||
|
const { q } = c.req.valid('query');
|
||||||
|
const games = await Game.searchByName(q ?? '');
|
||||||
|
return c.json({ data: games });
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.get(
|
||||||
|
'/:id',
|
||||||
|
describeRoute({
|
||||||
|
tags: ['Games'],
|
||||||
|
summary: 'Get a game by ID',
|
||||||
|
description: 'Retrieve a single game from the catalog',
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
content: {
|
||||||
|
'application/json': {
|
||||||
|
schema: Result(
|
||||||
|
Game.Info.meta({
|
||||||
|
description: 'The game',
|
||||||
|
example: Examples.Game
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
description: 'The game'
|
||||||
|
},
|
||||||
|
401: ErrorResponses[401],
|
||||||
|
404: ErrorResponses[404]
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
validator(
|
||||||
|
'param',
|
||||||
|
z.object({
|
||||||
|
id: z.string().meta({
|
||||||
|
description: 'ID of the game',
|
||||||
|
example: Examples.Game.id
|
||||||
|
})
|
||||||
|
})
|
||||||
|
),
|
||||||
|
async (c) => {
|
||||||
|
const { id } = c.req.valid('param');
|
||||||
|
const game = await Game.fromID(id);
|
||||||
|
if (!game) {
|
||||||
|
throw new VisibleError(
|
||||||
|
'not_found',
|
||||||
|
ErrorCodes.NotFound.RESOURCE_NOT_FOUND,
|
||||||
|
`Game ${id} not found`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return c.json({ data: game });
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.post(
|
||||||
|
'/sync',
|
||||||
|
adminOnly,
|
||||||
|
describeRoute({
|
||||||
|
tags: ['Games'],
|
||||||
|
summary: 'Batch sync games, library entries, and depots',
|
||||||
|
description:
|
||||||
|
'Bulk upsert games, library entries, and depot info from Steam sync. Admin only.',
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
content: {
|
||||||
|
'application/json': {
|
||||||
|
schema: Result(
|
||||||
|
z.object({
|
||||||
|
gamesSynced: z.number(),
|
||||||
|
libraryEntries: z.number(),
|
||||||
|
depotEntries: z.number(),
|
||||||
|
failedEntries: z.array(z.number())
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
description: 'Sync result'
|
||||||
|
},
|
||||||
|
400: ErrorResponses[400],
|
||||||
|
401: ErrorResponses[401],
|
||||||
|
403: ErrorResponses[403]
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
validator(
|
||||||
|
'json',
|
||||||
|
z.object({
|
||||||
|
userId: z.string(),
|
||||||
|
games: z.array(SyncGameSchema).default([]),
|
||||||
|
library: z.array(SyncLibrarySchema).default([])
|
||||||
|
})
|
||||||
|
),
|
||||||
|
async (c) => {
|
||||||
|
const { userId, games, library } = c.req.valid('json');
|
||||||
|
|
||||||
|
const existingGames = await Game.listByAppIDs(games.map((g) => g.steamAppId));
|
||||||
|
const existingByAppId = new Map(existingGames.map((g) => [g.steamAppId, g]));
|
||||||
|
const gameIdByAppId = new Map<number, string>();
|
||||||
|
|
||||||
|
const failedSteamIDs = new Set<number>();
|
||||||
|
const gamePromises = [];
|
||||||
|
|
||||||
|
// 1. Queue Games
|
||||||
|
for (const g of games) {
|
||||||
|
const existing = existingByAppId.get(g.steamAppId);
|
||||||
|
const gameId = existing?.id ?? Identifier.ascending('game');
|
||||||
|
|
||||||
|
gameIdByAppId.set(g.steamAppId, gameId);
|
||||||
|
|
||||||
|
const slug =
|
||||||
|
g.name
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, '-')
|
||||||
|
.replace(/^-|-$/g, '') || `app-${g.steamAppId}`;
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const { enriched } = g;
|
||||||
|
|
||||||
|
gamePromises.push(
|
||||||
|
Game.upsert({
|
||||||
|
id: gameId,
|
||||||
|
steamAppId: g.steamAppId,
|
||||||
|
slug,
|
||||||
|
name: g.name,
|
||||||
|
type: g.type ?? null,
|
||||||
|
clientIcon: g.clientIcon ?? null,
|
||||||
|
icon: g.icon ?? null,
|
||||||
|
shortDescription: g.shortDescription ?? null,
|
||||||
|
description: g.description ?? null,
|
||||||
|
developers: g.developers ?? null,
|
||||||
|
publishers: g.publishers ?? null,
|
||||||
|
primaryGenre: g.primaryGenre ?? null,
|
||||||
|
genres: g.genres ?? null,
|
||||||
|
categories: g.categories ?? null,
|
||||||
|
oslist: g.oslist ?? null,
|
||||||
|
sizeDownload: g.sizeDownload ?? null,
|
||||||
|
sizeOnDisk: g.sizeOnDisk ?? null,
|
||||||
|
controllerSupport: g.controllerSupport ?? null,
|
||||||
|
steamDeckCompat: g.steamDeckCompat ?? null,
|
||||||
|
reviewScorePercent: g.reviewScorePercent ?? null,
|
||||||
|
reviewCount: g.reviewCount ?? null,
|
||||||
|
metacriticScore: g.metacriticScore ?? null,
|
||||||
|
steamChangeNumber: g.steamChangeNumber ?? null,
|
||||||
|
publicBuildId: g.publicBuildId ?? null,
|
||||||
|
releaseDate: g.releaseDate ?? null,
|
||||||
|
timeEnriched: enriched ? now : (existing?.timeEnriched?.toISOString() ?? null)
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const gameResults = await Promise.allSettled(gamePromises);
|
||||||
|
let gamesSynced = 0;
|
||||||
|
|
||||||
|
const depotPromises = [];
|
||||||
|
const depotSteamIds = [];
|
||||||
|
const libraryPromises = [];
|
||||||
|
const librarySteamIds = [];
|
||||||
|
|
||||||
|
// 2. Evaluate Games & Queue Dependents
|
||||||
|
for (let i = 0; i < gameResults.length; i++) {
|
||||||
|
const g = games[i];
|
||||||
|
|
||||||
|
if (gameResults[i].status === 'rejected') {
|
||||||
|
failedSteamIDs.add(g.steamAppId);
|
||||||
|
// Drop it from the map so the Library loop below ignores it
|
||||||
|
gameIdByAppId.delete(g.steamAppId);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
gamesSynced++;
|
||||||
|
|
||||||
|
if (g.depots) {
|
||||||
|
const gameId = gameIdByAppId.get(g.steamAppId)!;
|
||||||
|
for (const d of g.depots) {
|
||||||
|
const depotId = Identifier.ascending('gameDepot');
|
||||||
|
depotPromises.push(
|
||||||
|
Depot.upsert({
|
||||||
|
id: depotId,
|
||||||
|
gameId: gameId,
|
||||||
|
depotId: d.depotId,
|
||||||
|
branch: d.branch,
|
||||||
|
steamManifestId: d.steamManifestId ?? null,
|
||||||
|
steamBuildId: d.steamBuildId ?? null,
|
||||||
|
sizeDownload: d.sizeDownload ?? null,
|
||||||
|
sizeOnDisk: d.sizeOnDisk ?? null,
|
||||||
|
oslist: d.oslist ?? null,
|
||||||
|
status: 'pending' as const
|
||||||
|
})
|
||||||
|
);
|
||||||
|
depotSteamIds.push(g.steamAppId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const l of library) {
|
||||||
|
// This naturally filters out entries for games that failed in step 2
|
||||||
|
const gameId = gameIdByAppId.get(l.steamAppId);
|
||||||
|
if (!gameId) continue;
|
||||||
|
|
||||||
|
const entryId = Identifier.ascending('userLibrary');
|
||||||
|
libraryPromises.push(
|
||||||
|
Library.upsert({
|
||||||
|
id: entryId,
|
||||||
|
userId,
|
||||||
|
gameId,
|
||||||
|
playtime2w: l.playtime2WeeksMin ?? null,
|
||||||
|
playtimeForever: l.playtimeForeverMin ?? null,
|
||||||
|
lastPlayed: l.lastPlayed ?? null
|
||||||
|
})
|
||||||
|
);
|
||||||
|
librarySteamIds.push(l.steamAppId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Execute Dependents in parallel
|
||||||
|
const [depotResults, libraryResults] = await Promise.all([
|
||||||
|
Promise.allSettled(depotPromises),
|
||||||
|
Promise.allSettled(libraryPromises)
|
||||||
|
]);
|
||||||
|
|
||||||
|
let depotEntries = 0;
|
||||||
|
for (let i = 0; i < depotResults.length; i++) {
|
||||||
|
if (depotResults[i].status === 'rejected') {
|
||||||
|
failedSteamIDs.add(depotSteamIds[i]);
|
||||||
|
} else {
|
||||||
|
depotEntries++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let libraryEntries = 0;
|
||||||
|
for (let i = 0; i < libraryResults.length; i++) {
|
||||||
|
if (libraryResults[i].status === 'rejected') {
|
||||||
|
failedSteamIDs.add(librarySteamIds[i]);
|
||||||
|
} else {
|
||||||
|
libraryEntries++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json({
|
||||||
|
data: {
|
||||||
|
gamesSynced,
|
||||||
|
libraryEntries,
|
||||||
|
depotEntries,
|
||||||
|
failedEntries: Array.from(failedSteamIDs)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.get(
|
||||||
|
'/:id/download-state',
|
||||||
|
describeRoute({
|
||||||
|
tags: ['Games'],
|
||||||
|
summary: 'Get download states for a game',
|
||||||
|
description:
|
||||||
|
'Returns the per-host download states for a game. Optionally filter by hostId. Protected read route for initial/fallback data; SSH-connected clients use the live SSH snapshot.',
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
content: {
|
||||||
|
'application/json': {
|
||||||
|
schema: Result(
|
||||||
|
z.array(GameDownload.Info).meta({
|
||||||
|
description: 'Download states for the game',
|
||||||
|
example: [Examples.GameDownload]
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
description: 'Download states'
|
||||||
|
},
|
||||||
|
401: ErrorResponses[401],
|
||||||
|
404: ErrorResponses[404]
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
validator(
|
||||||
|
'param',
|
||||||
|
z.object({
|
||||||
|
id: z.string().meta({
|
||||||
|
description: 'ID of the game',
|
||||||
|
example: Examples.Game.id
|
||||||
|
})
|
||||||
|
})
|
||||||
|
),
|
||||||
|
validator(
|
||||||
|
'query',
|
||||||
|
z.object({
|
||||||
|
hostId: z.string().optional().meta({
|
||||||
|
description: 'Optional host ID to filter by',
|
||||||
|
example: Examples.GameDownload.hostId
|
||||||
|
})
|
||||||
|
})
|
||||||
|
),
|
||||||
|
async (c) => {
|
||||||
|
const { id } = c.req.valid('param');
|
||||||
|
const { hostId } = c.req.valid('query');
|
||||||
|
|
||||||
|
const game = await Game.fromID(id);
|
||||||
|
if (!game) {
|
||||||
|
throw new VisibleError(
|
||||||
|
'not_found',
|
||||||
|
ErrorCodes.NotFound.RESOURCE_NOT_FOUND,
|
||||||
|
`Game ${id} not found`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = hostId
|
||||||
|
? await GameDownload.findByHostAndGame({ hostId, gameId: id }).then((row) =>
|
||||||
|
row ? [row] : []
|
||||||
|
)
|
||||||
|
: await GameDownload.listByGame(id);
|
||||||
|
const data = rows.map((row) => GameDownload.serialize(row));
|
||||||
|
return c.json({ data });
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.post(
|
||||||
|
'/download-state',
|
||||||
|
machineOrAdmin,
|
||||||
|
describeRoute({
|
||||||
|
tags: ['Games'],
|
||||||
|
summary: 'Report a download state change',
|
||||||
|
description:
|
||||||
|
'Update the shared per-host download state for a game. Called by nessh on terminal events (start/verifying/complete/fail). A registered host reports as itself and cannot name another; admin must supply the hostId explicitly.',
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
content: {
|
||||||
|
'application/json': {
|
||||||
|
schema: Result(
|
||||||
|
z.object({
|
||||||
|
downloadId: z.string(),
|
||||||
|
download: GameDownload.Info
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
description: 'Download state updated'
|
||||||
|
},
|
||||||
|
400: ErrorResponses[400],
|
||||||
|
401: ErrorResponses[401],
|
||||||
|
403: ErrorResponses[403],
|
||||||
|
404: ErrorResponses[404]
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
validator(
|
||||||
|
'json',
|
||||||
|
z.object({
|
||||||
|
hostId: z.string().optional().meta({
|
||||||
|
description:
|
||||||
|
'The nessh host reporting the download. Required for admin callers; ignored for machines, which report as themselves.',
|
||||||
|
example: Examples.GameDownload.hostId
|
||||||
|
}),
|
||||||
|
steamAppId: z.number().int().meta({
|
||||||
|
description: 'Steam application ID',
|
||||||
|
example: Examples.Game.steamAppId
|
||||||
|
}),
|
||||||
|
status: z.enum(GameDownloadStatus.enumValues).meta({
|
||||||
|
description: 'New download status',
|
||||||
|
example: Examples.GameDownload.status
|
||||||
|
}),
|
||||||
|
progressBytes: z.number().int().optional().meta({
|
||||||
|
description: 'Bytes downloaded so far',
|
||||||
|
example: Examples.GameDownload.progressBytes
|
||||||
|
}),
|
||||||
|
totalBytes: z.number().int().optional().meta({
|
||||||
|
description: 'Total bytes to download',
|
||||||
|
example: Examples.GameDownload.totalBytes
|
||||||
|
}),
|
||||||
|
errorMessage: z.string().nullable().optional().meta({
|
||||||
|
description: 'Error message if status is failed',
|
||||||
|
example: null
|
||||||
|
})
|
||||||
|
})
|
||||||
|
),
|
||||||
|
async (c) => {
|
||||||
|
const { hostId, steamAppId, status, progressBytes, totalBytes, errorMessage } =
|
||||||
|
c.req.valid('json');
|
||||||
|
|
||||||
|
// A machine reports as itself. Taking the id from the body would
|
||||||
|
// mean any holder of a shared secret could write download state
|
||||||
|
// under any box's id, which is the whole reason boxes register.
|
||||||
|
const actor = Actor.use();
|
||||||
|
let reportingHostId: string;
|
||||||
|
if (actor.type === 'machine') {
|
||||||
|
if (hostId && hostId !== actor.properties.machineID) {
|
||||||
|
throw new VisibleError(
|
||||||
|
'forbidden',
|
||||||
|
ErrorCodes.Permission.FORBIDDEN,
|
||||||
|
'A machine may only report its own download state'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
reportingHostId = actor.properties.machineID;
|
||||||
|
} else {
|
||||||
|
if (!hostId) {
|
||||||
|
throw new VisibleError(
|
||||||
|
'validation',
|
||||||
|
ErrorCodes.Validation.MISSING_REQUIRED_FIELD,
|
||||||
|
'hostId is required when reporting on behalf of a host',
|
||||||
|
'hostId'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
reportingHostId = hostId;
|
||||||
|
}
|
||||||
|
|
||||||
|
const game = await Game.fromSteamAppID(steamAppId);
|
||||||
|
if (!game) {
|
||||||
|
throw new VisibleError(
|
||||||
|
'not_found',
|
||||||
|
ErrorCodes.NotFound.RESOURCE_NOT_FOUND,
|
||||||
|
`Game with steamAppId ${steamAppId} not found`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const row = await GameDownload.upsertState({
|
||||||
|
hostId: reportingHostId,
|
||||||
|
gameId: game.id,
|
||||||
|
status,
|
||||||
|
progressBytes: progressBytes ?? undefined,
|
||||||
|
totalBytes: totalBytes ?? undefined,
|
||||||
|
errorMessage: errorMessage ?? undefined
|
||||||
|
});
|
||||||
|
|
||||||
|
return c.json({
|
||||||
|
data: { downloadId: row.id, download: GameDownload.serialize(row) }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.post(
|
||||||
|
'/',
|
||||||
|
adminOnly,
|
||||||
|
describeRoute({
|
||||||
|
tags: ['Games'],
|
||||||
|
summary: 'Create or update a game',
|
||||||
|
description: 'Upsert a game by Steam app ID. Admin only.',
|
||||||
|
responses: {
|
||||||
|
201: {
|
||||||
|
content: {
|
||||||
|
'application/json': {
|
||||||
|
schema: Result(
|
||||||
|
Game.Info.meta({
|
||||||
|
description: 'The created or updated game',
|
||||||
|
example: Examples.Game
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
description: 'Game created or updated'
|
||||||
|
},
|
||||||
|
400: ErrorResponses[400],
|
||||||
|
401: ErrorResponses[401],
|
||||||
|
403: ErrorResponses[403]
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
validator(
|
||||||
|
'json',
|
||||||
|
z.object({
|
||||||
|
steamAppId: z.number().int().meta({
|
||||||
|
description: 'Steam application ID',
|
||||||
|
example: Examples.Game.steamAppId
|
||||||
|
}),
|
||||||
|
name: z.string().meta({
|
||||||
|
description: 'Game title',
|
||||||
|
example: Examples.Game.name
|
||||||
|
}),
|
||||||
|
slug: z.string().optional().meta({
|
||||||
|
description: 'URL-friendly slug',
|
||||||
|
example: Examples.Game.slug
|
||||||
|
}),
|
||||||
|
type: z.string().nullable().optional().meta({
|
||||||
|
description: 'Content type',
|
||||||
|
example: Examples.Game.type
|
||||||
|
}),
|
||||||
|
clientIcon: z.string().nullable().optional().meta({
|
||||||
|
description: 'Steam client icon hash (256×256 square)',
|
||||||
|
example: Examples.Game.clientIcon
|
||||||
|
}),
|
||||||
|
icon: z.string().nullable().optional().meta({
|
||||||
|
description: 'Steam icon hash (32×32)',
|
||||||
|
example: Examples.Game.icon
|
||||||
|
}),
|
||||||
|
shortDescription: z.string().nullable().optional().meta({
|
||||||
|
description: 'Short description',
|
||||||
|
example: Examples.Game.shortDescription
|
||||||
|
}),
|
||||||
|
description: z.string().nullable().optional().meta({
|
||||||
|
description: 'Full description',
|
||||||
|
example: Examples.Game.description
|
||||||
|
}),
|
||||||
|
developers: z.array(z.string()).nullable().optional().meta({
|
||||||
|
description: 'Game developers',
|
||||||
|
example: Examples.Game.developers
|
||||||
|
}),
|
||||||
|
publishers: z.array(z.string()).nullable().optional().meta({
|
||||||
|
description: 'Game publishers',
|
||||||
|
example: Examples.Game.publishers
|
||||||
|
}),
|
||||||
|
genres: z.array(z.string()).nullable().optional().meta({
|
||||||
|
description: 'Game genres',
|
||||||
|
example: Examples.Game.genres
|
||||||
|
}),
|
||||||
|
oslist: z.array(z.string()).nullable().optional().meta({
|
||||||
|
description: 'Supported OS list',
|
||||||
|
example: Examples.Game.oslist
|
||||||
|
}),
|
||||||
|
releaseDate: z.string().nullable().optional().meta({
|
||||||
|
description: 'Release date ISO string',
|
||||||
|
example: Examples.Game.releaseDate
|
||||||
|
})
|
||||||
|
})
|
||||||
|
),
|
||||||
|
async (c) => {
|
||||||
|
const body = c.req.valid('json');
|
||||||
|
const id = Identifier.ascending('game');
|
||||||
|
const slug =
|
||||||
|
body.slug ??
|
||||||
|
body.name
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, '-')
|
||||||
|
.replace(/^-|-$/g, '');
|
||||||
|
const game = await Game.upsert({ ...body, id, slug });
|
||||||
|
return c.json({ data: game[0] }, 201);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
19
apps/api/app/routes/index.ts
Normal file
19
apps/api/app/routes/index.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { Database } from '@nestri/core/db/index';
|
||||||
|
import { ErrorCodes, VisibleError } from '@nestri/core/error';
|
||||||
|
import { Hono } from 'hono';
|
||||||
|
|
||||||
|
export namespace IndexApi {
|
||||||
|
export const route = new Hono()
|
||||||
|
.get('/', (c) => c.text('Hello World!'))
|
||||||
|
.get('/health', async (c) => {
|
||||||
|
const ok = await Database.ping();
|
||||||
|
if (!ok) {
|
||||||
|
throw new VisibleError(
|
||||||
|
'internal',
|
||||||
|
ErrorCodes.Server.DEPENDENCY_FAILURE,
|
||||||
|
'Database connection failed'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return c.json({ status: 'ok' });
|
||||||
|
});
|
||||||
|
}
|
||||||
211
apps/api/app/routes/library.ts
Normal file
211
apps/api/app/routes/library.ts
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
import { Actor } from '@nestri/core/actor';
|
||||||
|
import { Examples } from '@nestri/core/examples';
|
||||||
|
import { GameDownload } from '@nestri/core/game/download';
|
||||||
|
import { Game } from '@nestri/core/game/index';
|
||||||
|
import { Identifier } from '@nestri/core/id';
|
||||||
|
import { Library } from '@nestri/core/user/library';
|
||||||
|
import { Hono } from 'hono';
|
||||||
|
import { describeRoute } from 'hono-openapi';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { ErrorResponses, adminOnly, notPublic, Result, validator } from '../utils';
|
||||||
|
|
||||||
|
export namespace LibraryApi {
|
||||||
|
export const route = new Hono()
|
||||||
|
.use(notPublic)
|
||||||
|
.get(
|
||||||
|
'/',
|
||||||
|
describeRoute({
|
||||||
|
tags: ['Library'],
|
||||||
|
summary: "List the user's Steam library",
|
||||||
|
description:
|
||||||
|
"Returns all games in the authenticated user's library with playtime info and shared per-host download states.",
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
content: {
|
||||||
|
'application/json': {
|
||||||
|
schema: Result(
|
||||||
|
z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
|
id: Library.Info.shape.id,
|
||||||
|
game: Game.Info,
|
||||||
|
playtime2w: Library.Info.shape.playtime2w,
|
||||||
|
playtimeForever: Library.Info.shape.playtimeForever,
|
||||||
|
lastPlayed: Library.Info.shape.lastPlayed,
|
||||||
|
download: GameDownload.Info.nullable()
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.meta({
|
||||||
|
description: 'Library entries with game data',
|
||||||
|
example: [Examples.Library]
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
description: 'Library entries'
|
||||||
|
},
|
||||||
|
400: ErrorResponses[400],
|
||||||
|
401: ErrorResponses[401]
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
async (c) => {
|
||||||
|
const data = await Library.listByUserWithGames(Actor.userID);
|
||||||
|
return c.json({ data });
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.post(
|
||||||
|
'/sync',
|
||||||
|
adminOnly,
|
||||||
|
describeRoute({
|
||||||
|
tags: ['Library'],
|
||||||
|
summary: "Sync a user's Steam library",
|
||||||
|
description:
|
||||||
|
'Batch upsert games and library entries for a user from Steam owned games data. Admin only.',
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
content: {
|
||||||
|
'application/json': {
|
||||||
|
schema: Result(
|
||||||
|
z.object({
|
||||||
|
gamesSynced: z.number(),
|
||||||
|
libraryEntries: z.number(),
|
||||||
|
failedEntries: z.array(z.number())
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
description: 'Sync result'
|
||||||
|
},
|
||||||
|
400: ErrorResponses[400],
|
||||||
|
401: ErrorResponses[401],
|
||||||
|
403: ErrorResponses[403]
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
validator(
|
||||||
|
'json',
|
||||||
|
z.object({
|
||||||
|
userId: z.string().meta({
|
||||||
|
description: 'The user to sync library for',
|
||||||
|
example: Examples.User.id
|
||||||
|
}),
|
||||||
|
games: z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
|
steamAppId: z.number().int().meta({
|
||||||
|
description: 'Steam application ID',
|
||||||
|
example: Examples.Game.steamAppId
|
||||||
|
}),
|
||||||
|
name: z.string().meta({
|
||||||
|
description: 'Game title',
|
||||||
|
example: Examples.Game.name
|
||||||
|
}),
|
||||||
|
playtimeForever: z.number().int().optional().meta({
|
||||||
|
description: 'Total playtime in minutes',
|
||||||
|
example: Examples.Library.playtimeForever
|
||||||
|
}),
|
||||||
|
playtime2w: z.number().int().optional().meta({
|
||||||
|
description: 'Playtime in last 2 weeks in minutes',
|
||||||
|
example: Examples.Library.playtime2w
|
||||||
|
}),
|
||||||
|
rtimeLastPlayed: z.number().int().optional().meta({
|
||||||
|
description: 'Last played unix timestamp',
|
||||||
|
example: 1_700_000_000
|
||||||
|
})
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.meta({
|
||||||
|
description: 'Games to sync',
|
||||||
|
example: [Examples.Game]
|
||||||
|
})
|
||||||
|
})
|
||||||
|
),
|
||||||
|
async (c) => {
|
||||||
|
const { userId, games } = c.req.valid('json');
|
||||||
|
|
||||||
|
const existingGames = await Game.listByAppIDs(games.map((g) => g.steamAppId));
|
||||||
|
const existingByAppId = new Map(existingGames.map((g) => [g.steamAppId, g]));
|
||||||
|
|
||||||
|
const failedSteamIDs = new Set<number>();
|
||||||
|
const gamePromises = [];
|
||||||
|
const gameIds = []; // Storing generated IDs to use in the next step
|
||||||
|
|
||||||
|
// 1. Queue Games
|
||||||
|
for (const g of games) {
|
||||||
|
const existing = existingByAppId.get(g.steamAppId);
|
||||||
|
const gameId = existing?.id ?? Identifier.ascending('game');
|
||||||
|
gameIds.push(gameId); // Aligns with games array index
|
||||||
|
|
||||||
|
const slug =
|
||||||
|
g.name
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, '-')
|
||||||
|
.replace(/^-|-$/g, '') || `app-${g.steamAppId}`;
|
||||||
|
|
||||||
|
gamePromises.push(
|
||||||
|
Game.upsert({
|
||||||
|
id: gameId,
|
||||||
|
steamAppId: g.steamAppId,
|
||||||
|
slug,
|
||||||
|
name: g.name
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const gameResults = await Promise.allSettled(gamePromises);
|
||||||
|
let gamesSynced = 0;
|
||||||
|
|
||||||
|
const libraryPromises = [];
|
||||||
|
const librarySteamIds = []; // To track which promise belongs to which app
|
||||||
|
|
||||||
|
// 2. Evaluate Games & Queue Libraries for Successes
|
||||||
|
for (let i = 0; i < gameResults.length; i++) {
|
||||||
|
const g = games[i];
|
||||||
|
|
||||||
|
if (gameResults[i].status === 'rejected') {
|
||||||
|
failedSteamIDs.add(g.steamAppId);
|
||||||
|
continue; // Skip queuing library upsert if the game failed
|
||||||
|
}
|
||||||
|
|
||||||
|
gamesSynced++;
|
||||||
|
|
||||||
|
const entryId = Identifier.ascending('userLibrary');
|
||||||
|
const lastPlayed = g.rtimeLastPlayed
|
||||||
|
? new Date(g.rtimeLastPlayed * 1000).toISOString()
|
||||||
|
: null;
|
||||||
|
|
||||||
|
libraryPromises.push(
|
||||||
|
Library.upsert({
|
||||||
|
id: entryId,
|
||||||
|
userId,
|
||||||
|
gameId: gameIds[i],
|
||||||
|
playtime2w: g.playtime2w ?? null,
|
||||||
|
playtimeForever: g.playtimeForever ?? null,
|
||||||
|
lastPlayed
|
||||||
|
})
|
||||||
|
);
|
||||||
|
librarySteamIds.push(g.steamAppId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Execute Libraries
|
||||||
|
const libraryResults = await Promise.allSettled(libraryPromises);
|
||||||
|
let libraryEntries = 0;
|
||||||
|
|
||||||
|
for (let i = 0; i < libraryResults.length; i++) {
|
||||||
|
if (libraryResults[i].status === 'rejected') {
|
||||||
|
failedSteamIDs.add(librarySteamIds[i]);
|
||||||
|
} else {
|
||||||
|
libraryEntries++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json({
|
||||||
|
data: {
|
||||||
|
gamesSynced,
|
||||||
|
libraryEntries,
|
||||||
|
failedEntries: Array.from(failedSteamIDs)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
231
apps/api/app/routes/machine.ts
Normal file
231
apps/api/app/routes/machine.ts
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
import { Actor } from '@nestri/core/actor';
|
||||||
|
import { ErrorCodes, VisibleError } from '@nestri/core/error';
|
||||||
|
import { Examples } from '@nestri/core/examples';
|
||||||
|
import { Identifier } from '@nestri/core/id';
|
||||||
|
import { Machine } from '@nestri/core/machine/index';
|
||||||
|
import { Member } from '@nestri/core/team/member';
|
||||||
|
import { Hono } from 'hono';
|
||||||
|
import { describeRoute } from 'hono-openapi';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { ErrorResponses, machineOnly, notPublic, Result, validator } from '../utils';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Host registration.
|
||||||
|
*
|
||||||
|
* A box does not get to say who it is. It registers once against its owner's
|
||||||
|
* session, is handed an id and a secret, and authenticates as itself from then
|
||||||
|
* on — so `hostId` on a download report is something the API assigned rather
|
||||||
|
* than a free-form string any holder of a shared secret could invent.
|
||||||
|
*/
|
||||||
|
export namespace MachineApi {
|
||||||
|
export const route = new Hono()
|
||||||
|
.post(
|
||||||
|
'/register',
|
||||||
|
notPublic,
|
||||||
|
describeRoute({
|
||||||
|
tags: ['Machine'],
|
||||||
|
summary: 'Register a nessh host',
|
||||||
|
description:
|
||||||
|
'Exchange the calling user session for a machine id and secret. The secret is returned once and never again — it is stored only as a digest.',
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
content: {
|
||||||
|
'application/json': {
|
||||||
|
schema: Result(
|
||||||
|
z.object({
|
||||||
|
machineId: z.string().meta({ example: Examples.Machine.id }),
|
||||||
|
secret: z.string().meta({
|
||||||
|
description: 'Shown once. Store it on the box; it cannot be retrieved.'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
description: 'The box is registered'
|
||||||
|
},
|
||||||
|
401: ErrorResponses[401],
|
||||||
|
403: ErrorResponses[403]
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
validator(
|
||||||
|
'json',
|
||||||
|
z.object({
|
||||||
|
label: z.string().min(1).max(64).meta({
|
||||||
|
description: 'Human-readable name for the box',
|
||||||
|
example: Examples.Machine.label
|
||||||
|
}),
|
||||||
|
teamId: z.string().optional().meta({
|
||||||
|
description: 'Register the box into a team rather than to the user alone'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
),
|
||||||
|
async (c) => {
|
||||||
|
const { label, teamId } = c.req.valid('json');
|
||||||
|
|
||||||
|
// `notPublic` also admits admin, which has no user to own the box.
|
||||||
|
// Registering is an act of ownership, so it needs a real one.
|
||||||
|
const actor = Actor.use();
|
||||||
|
if (actor.type !== 'user' && actor.type !== 'member') {
|
||||||
|
throw new VisibleError(
|
||||||
|
'forbidden',
|
||||||
|
ErrorCodes.Permission.INSUFFICIENT_PERMISSIONS,
|
||||||
|
'Registering a machine requires a user session'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const registered = await Machine.register({
|
||||||
|
id: Identifier.ascending('machine'),
|
||||||
|
ownerUserId: Actor.userID,
|
||||||
|
teamId: teamId ?? (actor.type === 'member' ? actor.properties.teamID : null),
|
||||||
|
label
|
||||||
|
});
|
||||||
|
|
||||||
|
return c.json({ data: { machineId: registered.id, secret: registered.secret } });
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.patch(
|
||||||
|
'/:id',
|
||||||
|
notPublic,
|
||||||
|
describeRoute({
|
||||||
|
tags: ['Machine'],
|
||||||
|
summary: 'Move a box into a team, or out of one',
|
||||||
|
description:
|
||||||
|
'Scope a machine you own to a team you belong to, or pass teamId: null to make it yours alone again. This is not ownership transfer — the owner does not change.',
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
content: { 'application/json': { schema: Result(Machine.Info) } },
|
||||||
|
description: 'The machine, rescoped'
|
||||||
|
},
|
||||||
|
401: ErrorResponses[401],
|
||||||
|
403: ErrorResponses[403],
|
||||||
|
404: ErrorResponses[404]
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
validator(
|
||||||
|
'json',
|
||||||
|
z.object({
|
||||||
|
teamId: z.string().nullable().meta({
|
||||||
|
description: 'Team to scope the box to, or null to scope it to you alone'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
),
|
||||||
|
async (c) => {
|
||||||
|
const { teamId } = c.req.valid('json');
|
||||||
|
|
||||||
|
const actor = Actor.use();
|
||||||
|
if (actor.type !== 'user' && actor.type !== 'member') {
|
||||||
|
throw new VisibleError(
|
||||||
|
'forbidden',
|
||||||
|
ErrorCodes.Permission.INSUFFICIENT_PERMISSIONS,
|
||||||
|
'Rescoping a machine requires a user session'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verified before the write. `setTeam` scopes to the owner but
|
||||||
|
// knows nothing about who belongs to the target team, so this is
|
||||||
|
// the only place that check exists.
|
||||||
|
if (teamId) {
|
||||||
|
const membership = await Member.findByTeamAndUser({
|
||||||
|
teamId,
|
||||||
|
userId: Actor.userID
|
||||||
|
});
|
||||||
|
if (!membership) {
|
||||||
|
throw new VisibleError(
|
||||||
|
'forbidden',
|
||||||
|
ErrorCodes.Permission.FORBIDDEN,
|
||||||
|
'You are not a member of that team'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const machine = await Machine.setTeam({
|
||||||
|
id: c.req.param('id'),
|
||||||
|
ownerUserId: Actor.userID,
|
||||||
|
teamId
|
||||||
|
});
|
||||||
|
if (!machine) {
|
||||||
|
// Owner-scoped in the query, so someone else's machine is a
|
||||||
|
// 404 rather than a 403 — no way to probe for ids.
|
||||||
|
throw new VisibleError(
|
||||||
|
'not_found',
|
||||||
|
ErrorCodes.NotFound.RESOURCE_NOT_FOUND,
|
||||||
|
'No such machine, or it is not yours'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return c.json({ data: machine });
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.get(
|
||||||
|
'/entitlement',
|
||||||
|
machineOnly,
|
||||||
|
describeRoute({
|
||||||
|
tags: ['Machine'],
|
||||||
|
summary: 'Ask whether a user may use this box',
|
||||||
|
description:
|
||||||
|
'Answers for the calling machine only — the machine is taken from its credentials, never from the query, so a box cannot ask about another. Membership is read live, so removing someone from a team removes their access.',
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
content: { 'application/json': { schema: Result(Machine.Entitlement) } },
|
||||||
|
description: 'Whether the user may use this machine, and why'
|
||||||
|
},
|
||||||
|
403: ErrorResponses[403]
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
validator('query', z.object({ userId: z.string().min(1) })),
|
||||||
|
async (c) => {
|
||||||
|
const { userId } = c.req.valid('query');
|
||||||
|
return c.json({
|
||||||
|
data: await Machine.entitlement({ machineId: Actor.machineID, userId })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.get(
|
||||||
|
'/me',
|
||||||
|
machineOnly,
|
||||||
|
describeRoute({
|
||||||
|
tags: ['Machine'],
|
||||||
|
summary: 'Describe the calling machine',
|
||||||
|
description:
|
||||||
|
'Returns the registration record for the credentials used. A box calls this at startup to confirm its credentials still work before relying on them.',
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
content: { 'application/json': { schema: Result(Machine.Info) } },
|
||||||
|
description: 'The calling machine'
|
||||||
|
},
|
||||||
|
403: ErrorResponses[403],
|
||||||
|
404: ErrorResponses[404]
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
async (c) => {
|
||||||
|
const machine = await Machine.fromID(Actor.machineID);
|
||||||
|
if (!machine) {
|
||||||
|
throw new VisibleError(
|
||||||
|
'not_found',
|
||||||
|
ErrorCodes.NotFound.RESOURCE_NOT_FOUND,
|
||||||
|
'This machine no longer exists'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return c.json({ data: machine });
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.get(
|
||||||
|
'/',
|
||||||
|
notPublic,
|
||||||
|
describeRoute({
|
||||||
|
tags: ['Machine'],
|
||||||
|
summary: 'List your registered hosts',
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
content: { 'application/json': { schema: Result(z.array(Machine.Info)) } },
|
||||||
|
description: 'Machines owned by the caller'
|
||||||
|
},
|
||||||
|
401: ErrorResponses[401],
|
||||||
|
403: ErrorResponses[403]
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
async (c) => {
|
||||||
|
return c.json({ data: await Machine.listByOwner(Actor.userID) });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
148
apps/api/app/routes/pairing-code.ts
Normal file
148
apps/api/app/routes/pairing-code.ts
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
import { Actor } from '@nestri/core/actor';
|
||||||
|
import { ErrorCodes, VisibleError } from '@nestri/core/error';
|
||||||
|
import { Examples } from '@nestri/core/examples';
|
||||||
|
import { Identifier } from '@nestri/core/id';
|
||||||
|
import { PairingCode } from '@nestri/core/pairing-code/index';
|
||||||
|
import { Fingerprint } from '@nestri/core/user/fingerprint';
|
||||||
|
import { Hono } from 'hono';
|
||||||
|
import { describeRoute } from 'hono-openapi';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { adminOnly, ErrorResponses, notPublic, Result, validator } from '../utils';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Device enrolment.
|
||||||
|
*
|
||||||
|
* A pairing code says "this SSH key is also me". It is deliberately not the
|
||||||
|
* same thing as an invite, which says "you may use my box" — same shape of
|
||||||
|
* secret, completely different authority, and merging them would let one be
|
||||||
|
* redeemed for the other.
|
||||||
|
*
|
||||||
|
* Generating requires an authenticated session; claiming is done by nessh on
|
||||||
|
* behalf of a device that has no identity yet, so it authenticates with the
|
||||||
|
* shared admin token instead.
|
||||||
|
*/
|
||||||
|
export namespace PairingCodeApi {
|
||||||
|
export const route = new Hono()
|
||||||
|
.post(
|
||||||
|
'/',
|
||||||
|
notPublic,
|
||||||
|
describeRoute({
|
||||||
|
tags: ['PairingCode'],
|
||||||
|
summary: 'Generate a pairing code',
|
||||||
|
description:
|
||||||
|
'Create a short-lived, single-use code that enrols another SSH key onto the current user.',
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
content: {
|
||||||
|
'application/json': {
|
||||||
|
schema: Result(
|
||||||
|
z.object({
|
||||||
|
code: z.string().meta({ example: Examples.PairingCode.code }),
|
||||||
|
expiresInMinutes: z.number()
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
description: 'A freshly generated pairing code'
|
||||||
|
},
|
||||||
|
401: ErrorResponses[401],
|
||||||
|
429: ErrorResponses[429]
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
validator(
|
||||||
|
'json',
|
||||||
|
z.object({
|
||||||
|
ttlMinutes: z.number().int().min(1).max(60).default(10).meta({
|
||||||
|
description: 'How long the code stays valid. Short by design.'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
),
|
||||||
|
async (c) => {
|
||||||
|
const { ttlMinutes } = c.req.valid('json');
|
||||||
|
const code = await PairingCode.create({
|
||||||
|
id: Identifier.ascending('pairingCode'),
|
||||||
|
targetUserId: Actor.userID,
|
||||||
|
ttlMinutes
|
||||||
|
});
|
||||||
|
|
||||||
|
return c.json({ data: { code, expiresInMinutes: ttlMinutes } });
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.post(
|
||||||
|
'/claim',
|
||||||
|
adminOnly,
|
||||||
|
describeRoute({
|
||||||
|
tags: ['PairingCode'],
|
||||||
|
summary: 'Claim a pairing code for an SSH key',
|
||||||
|
description:
|
||||||
|
'Redeem a code and bind the supplied SSH fingerprint to the user who generated it. Admin only: the calling device has no identity yet, which is the entire point.',
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
content: {
|
||||||
|
'application/json': {
|
||||||
|
schema: Result(
|
||||||
|
z.object({
|
||||||
|
userId: z.string().meta({ example: Examples.PairingCode.targetUserId })
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
description: 'The fingerprint now belongs to this user'
|
||||||
|
},
|
||||||
|
400: ErrorResponses[400],
|
||||||
|
403: ErrorResponses[403],
|
||||||
|
404: ErrorResponses[404]
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
validator(
|
||||||
|
'json',
|
||||||
|
z.object({
|
||||||
|
code: z.string().min(1).meta({ example: Examples.PairingCode.code }),
|
||||||
|
fingerprint: z.string().min(1).meta({
|
||||||
|
description: 'SSH public key fingerprint of the device being enrolled'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
),
|
||||||
|
async (c) => {
|
||||||
|
const { code, fingerprint } = c.req.valid('json');
|
||||||
|
|
||||||
|
// Refuse before claiming: a code is single-use, so burning one on
|
||||||
|
// a device that cannot be enrolled would strand the user.
|
||||||
|
const existing = await Fingerprint.findByFingerprint(fingerprint);
|
||||||
|
|
||||||
|
const claimed = await PairingCode.claim({ code, fingerprint });
|
||||||
|
if (!claimed) {
|
||||||
|
throw new VisibleError(
|
||||||
|
'not_found',
|
||||||
|
ErrorCodes.NotFound.RESOURCE_NOT_FOUND,
|
||||||
|
'That pairing code is unknown, already used, or expired'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existing && existing.userId !== claimed.targetUserId) {
|
||||||
|
// Handing a device between accounts is a different operation
|
||||||
|
// with its own consequences for anything already linked to it;
|
||||||
|
// `Steam.resolveSshIdentity` refuses the same case.
|
||||||
|
throw new VisibleError(
|
||||||
|
'forbidden',
|
||||||
|
ErrorCodes.Permission.FORBIDDEN,
|
||||||
|
'That SSH key is already enrolled to another user'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
await Fingerprint.touchLastSeen(existing.id);
|
||||||
|
} else {
|
||||||
|
await Fingerprint.create({
|
||||||
|
id: Identifier.ascending('userFingerprint'),
|
||||||
|
userId: claimed.targetUserId,
|
||||||
|
fingerprint,
|
||||||
|
name: null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json({ data: { userId: claimed.targetUserId } });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
86
apps/api/app/routes/steam.ts
Normal file
86
apps/api/app/routes/steam.ts
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import { Actor } from '@nestri/core/actor';
|
||||||
|
import { ErrorCodes, VisibleError } from '@nestri/core/error';
|
||||||
|
import { Examples } from '@nestri/core/examples';
|
||||||
|
import { Steam } from '@nestri/core/steam/index';
|
||||||
|
import { Hono } from 'hono';
|
||||||
|
import { describeRoute } from 'hono-openapi';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { ErrorResponses, notPublic, Result, validator } from '../utils';
|
||||||
|
|
||||||
|
export namespace SteamApi {
|
||||||
|
export const route = new Hono().use(notPublic).post(
|
||||||
|
'/link',
|
||||||
|
describeRoute({
|
||||||
|
tags: ['Steam'],
|
||||||
|
summary: 'Link a Steam account',
|
||||||
|
description: 'Link a Steam account to a user (admin) or yourself (user)',
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
content: {
|
||||||
|
'application/json': {
|
||||||
|
schema: Result(
|
||||||
|
z.object({
|
||||||
|
linkedAccountId: z.string().meta({
|
||||||
|
description: 'The ID of the linked account',
|
||||||
|
example: Examples.LinkedAccount.id
|
||||||
|
}),
|
||||||
|
steamId: z.string().meta({
|
||||||
|
description: 'The Steam ID that was linked',
|
||||||
|
example: '76561197960287930'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
description: 'Steam account linked'
|
||||||
|
},
|
||||||
|
400: ErrorResponses[400],
|
||||||
|
401: ErrorResponses[401],
|
||||||
|
403: ErrorResponses[403],
|
||||||
|
429: ErrorResponses[429]
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
validator(
|
||||||
|
'json',
|
||||||
|
z.object({
|
||||||
|
steamId: z.string().min(1).meta({
|
||||||
|
description: 'Steam ID to link',
|
||||||
|
example: '76561197960287930'
|
||||||
|
}),
|
||||||
|
userId: z.string().optional().meta({
|
||||||
|
description: 'User ID to link to (admin only; omitted when linking your own account)',
|
||||||
|
example: 'usr_XXXXXXXXXXXXXXXXXXXXXXXXX'
|
||||||
|
}),
|
||||||
|
profile: z
|
||||||
|
.record(z.string(), z.unknown())
|
||||||
|
.optional()
|
||||||
|
.meta({
|
||||||
|
description: 'Steam profile data',
|
||||||
|
example: { personaname: 'Player', avatarfull: 'https://...' }
|
||||||
|
})
|
||||||
|
})
|
||||||
|
),
|
||||||
|
async (c) => {
|
||||||
|
const body = c.req.valid('json');
|
||||||
|
const actor = Actor.use();
|
||||||
|
|
||||||
|
if (body.userId && actor.type !== 'admin') {
|
||||||
|
throw new VisibleError(
|
||||||
|
'forbidden',
|
||||||
|
ErrorCodes.Permission.INSUFFICIENT_PERMISSIONS,
|
||||||
|
'Only admin can link a Steam account for another user'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const linkedAccountID = await Steam.link({
|
||||||
|
steamId: body.steamId,
|
||||||
|
profile: body.profile,
|
||||||
|
userId: body.userId
|
||||||
|
});
|
||||||
|
return c.json({
|
||||||
|
data: { linkedAccountId: linkedAccountID, steamId: body.steamId }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
104
apps/api/app/routes/user.ts
Normal file
104
apps/api/app/routes/user.ts
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
import { Actor } from '@nestri/core/actor';
|
||||||
|
import { ErrorCodes, VisibleError } from '@nestri/core/error';
|
||||||
|
import { Examples } from '@nestri/core/examples';
|
||||||
|
import { User } from '@nestri/core/user/index';
|
||||||
|
import { Hono } from 'hono';
|
||||||
|
import { describeRoute } from 'hono-openapi';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { ErrorResponses, notPublic, Result, validator } from '../utils';
|
||||||
|
|
||||||
|
export namespace UserApi {
|
||||||
|
export const route = new Hono()
|
||||||
|
.use(notPublic)
|
||||||
|
.get(
|
||||||
|
'/',
|
||||||
|
describeRoute({
|
||||||
|
tags: ['User'],
|
||||||
|
summary: 'Get current user',
|
||||||
|
description: "Get the authenticated user's profile",
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
content: {
|
||||||
|
'application/json': {
|
||||||
|
schema: Result(
|
||||||
|
User.Info.meta({
|
||||||
|
description: 'Current user profile',
|
||||||
|
example: Examples.User
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
description: 'Current user'
|
||||||
|
},
|
||||||
|
400: ErrorResponses[400],
|
||||||
|
404: ErrorResponses[404],
|
||||||
|
429: ErrorResponses[429]
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
async (c) => {
|
||||||
|
const user = await User.fromID(Actor.userID);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new VisibleError(
|
||||||
|
'not_found',
|
||||||
|
ErrorCodes.NotFound.RESOURCE_NOT_FOUND,
|
||||||
|
'Authenticated user not found'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json({ data: user });
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.get(
|
||||||
|
'/:id',
|
||||||
|
describeRoute({
|
||||||
|
tags: ['User'],
|
||||||
|
summary: 'Get user',
|
||||||
|
description: 'Get a user by their ID',
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
content: {
|
||||||
|
'application/json': {
|
||||||
|
schema: Result(
|
||||||
|
User.Info.meta({
|
||||||
|
description: 'User details',
|
||||||
|
example: Examples.User
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
description: 'User details'
|
||||||
|
},
|
||||||
|
400: ErrorResponses[400],
|
||||||
|
429: ErrorResponses[429]
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
validator(
|
||||||
|
'param',
|
||||||
|
z.object({
|
||||||
|
id: z.string().meta({
|
||||||
|
description: 'ID of the user to get',
|
||||||
|
example: Examples.User.id
|
||||||
|
})
|
||||||
|
})
|
||||||
|
),
|
||||||
|
async (c) => {
|
||||||
|
const userID = c.req.valid('param').id;
|
||||||
|
|
||||||
|
const user = await User.fromID(userID);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new VisibleError(
|
||||||
|
'not_found',
|
||||||
|
ErrorCodes.NotFound.RESOURCE_NOT_FOUND,
|
||||||
|
`User ${userID} does not exist`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json({
|
||||||
|
data: user
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
1
apps/api/app/utils/auth.ts
Normal file
1
apps/api/app/utils/auth.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { auth, notPublic, adminOnly, machineOnly, machineOrAdmin } from '../middleware/auth.js';
|
||||||
127
apps/api/app/utils/error.ts
Normal file
127
apps/api/app/utils/error.ts
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
import { ErrorResponse } from '@nestri/core/error';
|
||||||
|
import { resolver } from 'hono-openapi/zod';
|
||||||
|
|
||||||
|
export const ErrorResponses = {
|
||||||
|
400: {
|
||||||
|
content: {
|
||||||
|
'application/json': {
|
||||||
|
schema: resolver(
|
||||||
|
ErrorResponse.meta({
|
||||||
|
description: 'Validation error',
|
||||||
|
example: {
|
||||||
|
type: 'validation',
|
||||||
|
code: 'invalid_parameter',
|
||||||
|
message: 'The request was invalid',
|
||||||
|
param: 'email'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
description:
|
||||||
|
'Bad Request - The request could not be understood or was missing required parameters.'
|
||||||
|
},
|
||||||
|
401: {
|
||||||
|
content: {
|
||||||
|
'application/json': {
|
||||||
|
schema: resolver(
|
||||||
|
ErrorResponse.meta({
|
||||||
|
description: 'Authentication error',
|
||||||
|
example: {
|
||||||
|
type: 'authentication',
|
||||||
|
code: 'unauthorized',
|
||||||
|
message: 'Authentication required'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
description:
|
||||||
|
'Unauthorized - Authentication is required and has failed or has not been provided.'
|
||||||
|
},
|
||||||
|
403: {
|
||||||
|
content: {
|
||||||
|
'application/json': {
|
||||||
|
schema: resolver(
|
||||||
|
ErrorResponse.meta({
|
||||||
|
description: 'Permission error',
|
||||||
|
example: {
|
||||||
|
type: 'forbidden',
|
||||||
|
code: 'permission_denied',
|
||||||
|
message: 'You do not have permission to access this resource'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
description: 'Forbidden - You do not have permission to access this resource.'
|
||||||
|
},
|
||||||
|
404: {
|
||||||
|
content: {
|
||||||
|
'application/json': {
|
||||||
|
schema: resolver(
|
||||||
|
ErrorResponse.meta({
|
||||||
|
description: 'Not found error',
|
||||||
|
example: {
|
||||||
|
type: 'not_found',
|
||||||
|
code: 'resource_not_found',
|
||||||
|
message: 'The requested resource could not be found'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
description: 'Not Found - The requested resource does not exist.'
|
||||||
|
},
|
||||||
|
409: {
|
||||||
|
content: {
|
||||||
|
'application/json': {
|
||||||
|
schema: resolver(
|
||||||
|
ErrorResponse.meta({
|
||||||
|
description: 'Conflict Error',
|
||||||
|
example: {
|
||||||
|
type: 'already_exists',
|
||||||
|
code: 'resource_already_exists',
|
||||||
|
message: 'The resource could not be created because it already exists'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
description: 'Conflict - The resource could not be created because it already exists.'
|
||||||
|
},
|
||||||
|
429: {
|
||||||
|
content: {
|
||||||
|
'application/json': {
|
||||||
|
schema: resolver(
|
||||||
|
ErrorResponse.meta({
|
||||||
|
description: 'Rate limit error',
|
||||||
|
example: {
|
||||||
|
type: 'rate_limit',
|
||||||
|
code: 'too_many_requests',
|
||||||
|
message: 'Rate limit exceeded'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
description: 'Too Many Requests - You have made too many requests in a short period of time.'
|
||||||
|
},
|
||||||
|
500: {
|
||||||
|
content: {
|
||||||
|
'application/json': {
|
||||||
|
schema: resolver(
|
||||||
|
ErrorResponse.meta({
|
||||||
|
description: 'Server error',
|
||||||
|
example: {
|
||||||
|
type: 'internal',
|
||||||
|
code: 'internal_error',
|
||||||
|
message: 'Internal server error'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
description: 'Internal Server Error - Something went wrong on our end.'
|
||||||
|
}
|
||||||
|
};
|
||||||
64
apps/api/app/utils/hook.ts
Normal file
64
apps/api/app/utils/hook.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import type {
|
||||||
|
Env,
|
||||||
|
ValidationTargets,
|
||||||
|
Context,
|
||||||
|
TypedResponse,
|
||||||
|
Input,
|
||||||
|
MiddlewareHandler
|
||||||
|
} from 'hono';
|
||||||
|
import { ZodError, ZodSchema, z } from 'zod';
|
||||||
|
|
||||||
|
type Hook<
|
||||||
|
T,
|
||||||
|
E extends Env,
|
||||||
|
P extends string,
|
||||||
|
Target extends keyof ValidationTargets = keyof ValidationTargets,
|
||||||
|
O = {}
|
||||||
|
> = (
|
||||||
|
result: (
|
||||||
|
| {
|
||||||
|
success: true;
|
||||||
|
data: T;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
success: false;
|
||||||
|
error: ZodError;
|
||||||
|
data: T;
|
||||||
|
}
|
||||||
|
) & {
|
||||||
|
target: Target;
|
||||||
|
},
|
||||||
|
c: Context<E, P>
|
||||||
|
) => Response | void | TypedResponse<O> | Promise<Response | void | TypedResponse<O>>;
|
||||||
|
type HasUndefined<T> = undefined extends T ? true : false;
|
||||||
|
declare const zValidator: <
|
||||||
|
T extends ZodSchema<any, z.ZodTypeDef, any>,
|
||||||
|
Target extends keyof ValidationTargets,
|
||||||
|
E extends Env,
|
||||||
|
P extends string,
|
||||||
|
In = z.input<T>,
|
||||||
|
Out = z.output<T>,
|
||||||
|
I extends Input = {
|
||||||
|
in: HasUndefined<In> extends true
|
||||||
|
? {
|
||||||
|
[K in Target]?:
|
||||||
|
| (In extends ValidationTargets[K]
|
||||||
|
? In
|
||||||
|
: { [K2 in keyof In]?: ValidationTargets[K][K2] | undefined })
|
||||||
|
| undefined;
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
[K_1 in Target]: In extends ValidationTargets[K_1]
|
||||||
|
? In
|
||||||
|
: { [K2_1 in keyof In]: ValidationTargets[K_1][K2_1] };
|
||||||
|
};
|
||||||
|
out: { [K_2 in Target]: Out };
|
||||||
|
},
|
||||||
|
V extends I = I
|
||||||
|
>(
|
||||||
|
target: Target,
|
||||||
|
schema: T,
|
||||||
|
hook?: Hook<z.TypeOf<T>, E, P, Target, {}> | undefined
|
||||||
|
) => MiddlewareHandler<E, P, V>;
|
||||||
|
|
||||||
|
export { type Hook, zValidator };
|
||||||
4
apps/api/app/utils/index.ts
Normal file
4
apps/api/app/utils/index.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export * from './auth';
|
||||||
|
export * from './error';
|
||||||
|
export * from './result';
|
||||||
|
export * from './validator';
|
||||||
6
apps/api/app/utils/result.ts
Normal file
6
apps/api/app/utils/result.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { resolver } from 'hono-openapi/zod';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
export function Result<T extends z.ZodTypeAny>(schema: T) {
|
||||||
|
return resolver(z.object({ data: schema }));
|
||||||
|
}
|
||||||
70
apps/api/app/utils/validator.ts
Normal file
70
apps/api/app/utils/validator.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import { ErrorCodes } from '@nestri/core/error';
|
||||||
|
import type { MiddlewareHandler, ValidationTargets } from 'hono';
|
||||||
|
import { validator as zodValidator } from 'hono-openapi/zod';
|
||||||
|
import { z, ZodSchema } from 'zod';
|
||||||
|
|
||||||
|
import type { Hook } from './hook';
|
||||||
|
|
||||||
|
type ZodIssueExtended = z.ZodIssue & {
|
||||||
|
expected?: unknown;
|
||||||
|
received?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const validator = <T extends ZodSchema, Target extends keyof ValidationTargets>(
|
||||||
|
target: Target,
|
||||||
|
schema: T
|
||||||
|
): MiddlewareHandler<
|
||||||
|
Record<string, unknown>,
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
in: {
|
||||||
|
[K in Target]: z.input<T>;
|
||||||
|
};
|
||||||
|
out: {
|
||||||
|
[K in Target]: z.output<T>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
> => {
|
||||||
|
const standardErrorHandler: Hook<z.infer<T>, any, any, Target> = (result, c) => {
|
||||||
|
if (!result.success) {
|
||||||
|
const issues = result.error.issues || result.error.errors || [];
|
||||||
|
const firstIssue = issues[0];
|
||||||
|
const fieldPath = Array.isArray(firstIssue?.path)
|
||||||
|
? firstIssue.path.join('.')
|
||||||
|
: firstIssue?.path;
|
||||||
|
|
||||||
|
let errorCode = ErrorCodes.Validation.INVALID_PARAMETER;
|
||||||
|
if (firstIssue?.code === 'invalid_type' && firstIssue?.received === 'undefined') {
|
||||||
|
errorCode = ErrorCodes.Validation.MISSING_REQUIRED_FIELD;
|
||||||
|
} else if (
|
||||||
|
['invalid_string', 'invalid_date', 'invalid_regex'].includes(firstIssue?.code as string)
|
||||||
|
) {
|
||||||
|
errorCode = ErrorCodes.Validation.INVALID_FORMAT;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = {
|
||||||
|
type: 'validation',
|
||||||
|
code: errorCode,
|
||||||
|
message: firstIssue?.message,
|
||||||
|
param: fieldPath,
|
||||||
|
details:
|
||||||
|
issues.length > 1
|
||||||
|
? {
|
||||||
|
issues: issues.map((issue: ZodIssueExtended) => ({
|
||||||
|
path: Array.isArray(issue.path) ? issue.path.join('.') : issue.path,
|
||||||
|
code: issue.code,
|
||||||
|
message: issue.message,
|
||||||
|
expected: issue.expected,
|
||||||
|
received: issue.received
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log('Validation error in validator:', response);
|
||||||
|
return c.json(response, 400);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return zodValidator(target, schema, standardErrorHandler);
|
||||||
|
};
|
||||||
23
apps/api/package.json
Normal file
23
apps/api/package.json
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"name": "api",
|
||||||
|
"type": "module",
|
||||||
|
"dependencies": {
|
||||||
|
"@hono/zod-validator": "^0.9.0",
|
||||||
|
"@nestri/auth": "workspace:",
|
||||||
|
"@nestri/core": "workspace:",
|
||||||
|
"hono": "catalog:",
|
||||||
|
"hono-openapi": "^0.4.8",
|
||||||
|
"jose": "^6.2.3",
|
||||||
|
"redis": "^6.0.0",
|
||||||
|
"zod": "catalog:",
|
||||||
|
"zod-openapi": "^6.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@cloudflare/workers-types": "catalog:",
|
||||||
|
"@types/bun": "catalog:",
|
||||||
|
"@types/node": "catalog:"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"typescript": "catalog:"
|
||||||
|
}
|
||||||
|
}
|
||||||
542
apps/api/test/routes.test.ts
Normal file
542
apps/api/test/routes.test.ts
Normal file
@@ -0,0 +1,542 @@
|
|||||||
|
import { describe, expect, test } from 'bun:test';
|
||||||
|
|
||||||
|
import { app } from '../app/index';
|
||||||
|
import { TEST_ADMIN_SECRET } from './setup';
|
||||||
|
import './setup';
|
||||||
|
|
||||||
|
function adminHeaders(): Record<string, string> {
|
||||||
|
return { 'x-nestri-admin-token': TEST_ADMIN_SECRET };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Index', () => {
|
||||||
|
test('GET / returns hello world', async () => {
|
||||||
|
const res = await app.request('/');
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(await res.text()).toBe('Hello World!');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Auth middleware', () => {
|
||||||
|
test('public access to a protected route returns 401', async () => {
|
||||||
|
const res = await app.request('/games');
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
const body = (await res.json()) as any;
|
||||||
|
expect(body.type).toBe('authentication');
|
||||||
|
expect(body.code).toBe('unauthorized');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('admin token gains access to protected routes', async () => {
|
||||||
|
const res = await app.request('/games', {
|
||||||
|
headers: adminHeaders()
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('wrong admin token is treated as public → 401', async () => {
|
||||||
|
const res = await app.request('/games', {
|
||||||
|
headers: { 'x-nestri-admin-token': 'wrong-secret' }
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
const body = (await res.json()) as any;
|
||||||
|
expect(body.type).toBe('authentication');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a bearer token that cannot be verified is unauthenticated, not a server error', async () => {
|
||||||
|
// A token nobody can verify makes the *caller* unauthenticated; it does
|
||||||
|
// not make the request a server fault. `verify` reports a malformed or
|
||||||
|
// expired token in `err`, but throws when it cannot reach the auth
|
||||||
|
// service at all, and that throw used to surface as a 500.
|
||||||
|
const res = await app.request('/games', {
|
||||||
|
headers: { authorization: 'Bearer not-a-real-token' }
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
const body = (await res.json()) as any;
|
||||||
|
expect(body.type).toBe('authentication');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('missing authorization on admin-only route returns 401', async () => {
|
||||||
|
const res = await app.request('/games/sync', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({})
|
||||||
|
});
|
||||||
|
// notPublic runs before adminOnly → 401
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
const body = (await res.json()) as any;
|
||||||
|
expect(body.code).toBe('unauthorized');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Validation', () => {
|
||||||
|
test('malformed JSON body returns 400', async () => {
|
||||||
|
const res = await app.request('/games/sync', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
...adminHeaders(),
|
||||||
|
'content-type': 'application/json'
|
||||||
|
},
|
||||||
|
body: '{not-json'
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
const body = (await res.json()) as any;
|
||||||
|
expect(body.type).toBe('validation');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('missing required fields returns 400 with code', async () => {
|
||||||
|
const res = await app.request('/games/download-state', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
...adminHeaders(),
|
||||||
|
'content-type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ status: 'downloading' })
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
const body = (await res.json()) as any;
|
||||||
|
expect(body.type).toBe('validation');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('invalid status enum in download-state returns 400', async () => {
|
||||||
|
const res = await app.request('/games/download-state', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
...adminHeaders(),
|
||||||
|
'content-type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
hostId: 'hst_test',
|
||||||
|
steamAppId: 440,
|
||||||
|
status: 'bogus_status'
|
||||||
|
})
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
const body = (await res.json()) as any;
|
||||||
|
expect(body.type).toBe('validation');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('non-existent game returns 404', async () => {
|
||||||
|
const res = await app.request('/games/gam_nonexistent', {
|
||||||
|
headers: adminHeaders()
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
const body = (await res.json()) as any;
|
||||||
|
expect(body.type).toBe('not_found');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('missing content-type header returns 400', async () => {
|
||||||
|
const res = await app.request('/games/sync', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: adminHeaders(),
|
||||||
|
body: JSON.stringify({})
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Error response shape', () => {
|
||||||
|
test('404 on unknown game has standard error shape', async () => {
|
||||||
|
const res = await app.request('/games/gam_nonexistent', {
|
||||||
|
headers: adminHeaders()
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
const body = (await res.json()) as any;
|
||||||
|
expect(body).toHaveProperty('type');
|
||||||
|
expect(body).toHaveProperty('code');
|
||||||
|
expect(body).toHaveProperty('message');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('429 error responses have standard shape', async () => {
|
||||||
|
const res = await app.request('/games/gam_nonexistent', {
|
||||||
|
headers: adminHeaders()
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
const body = (await res.json()) as any;
|
||||||
|
expect(body.type).toBe('not_found');
|
||||||
|
expect(body.code).toBe('resource_not_found');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('OpenAPI doc', () => {
|
||||||
|
test('GET /doc returns 200 with JSON', async () => {
|
||||||
|
const res = await app.request('/doc');
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = (await res.json()) as any;
|
||||||
|
expect(body).toHaveProperty('openapi');
|
||||||
|
expect(body.info.title).toBe('Nestri API');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('GET /doc contains expected route paths', async () => {
|
||||||
|
const res = await app.request('/doc');
|
||||||
|
const body = (await res.json()) as any;
|
||||||
|
const paths = Object.keys(body.paths);
|
||||||
|
expect(paths).toContain('/games');
|
||||||
|
expect(paths).toContain('/games/sync');
|
||||||
|
expect(paths).toContain('/games/{id}');
|
||||||
|
expect(paths).toContain('/games/{id}/download-state');
|
||||||
|
expect(paths).toContain('/games/download-state');
|
||||||
|
expect(paths).toContain('/library');
|
||||||
|
expect(paths).toContain('/library/sync');
|
||||||
|
expect(paths).toContain('/steam/link');
|
||||||
|
expect(paths).toContain('/user');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('doc has security schemes defined', async () => {
|
||||||
|
const res = await app.request('/doc');
|
||||||
|
const body = (await res.json()) as any;
|
||||||
|
expect(body.components.securitySchemes.Bearer).toMatchObject({
|
||||||
|
type: 'http',
|
||||||
|
scheme: 'bearer'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('CORS', () => {
|
||||||
|
test('CORS preflight returns headers', async () => {
|
||||||
|
const res = await app.request('/games', {
|
||||||
|
method: 'OPTIONS',
|
||||||
|
headers: {
|
||||||
|
origin: 'http://localhost:5173',
|
||||||
|
'access-control-request-method': 'GET'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(204);
|
||||||
|
expect(res.headers.get('access-control-allow-origin')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('response includes cache-control no-store', async () => {
|
||||||
|
const res = await app.request('/');
|
||||||
|
expect(res.headers.get('cache-control')).toBe('no-store');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Download state route', () => {
|
||||||
|
test('POST /games/download-state requires hostId and steamAppId', async () => {
|
||||||
|
const res = await app.request('/games/download-state', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
...adminHeaders(),
|
||||||
|
'content-type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
hostId: 'hst_test',
|
||||||
|
status: 'downloading'
|
||||||
|
// missing steamAppId
|
||||||
|
})
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('POST /games/download-state validates status enum', async () => {
|
||||||
|
const valid = ['pending', 'verifying', 'downloading', 'ready', 'failed'] as const;
|
||||||
|
for (const status of valid) {
|
||||||
|
//eslint-disable-next-line
|
||||||
|
const res = await app.request('/games/download-state', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
...adminHeaders(),
|
||||||
|
'content-type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
hostId: 'hst_test',
|
||||||
|
steamAppId: 440,
|
||||||
|
status
|
||||||
|
})
|
||||||
|
});
|
||||||
|
// Validation should pass (200 or 404 if game not in DB)
|
||||||
|
expect(res.status).not.toBe(400);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an unauthenticated caller cannot report download state', async () => {
|
||||||
|
const res = await app.request('/games/download-state', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ hostId: 'mch_test', steamAppId: 440, status: 'ready' })
|
||||||
|
});
|
||||||
|
// The route group's `notPublic` runs first, so this is 401 rather than
|
||||||
|
// the 403 `machineOrAdmin` would give an authenticated non-host.
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an admin caller must say which host it is reporting for', async () => {
|
||||||
|
// hostId is optional in the schema now because a machine supplies it
|
||||||
|
// from its own identity. Admin has no identity to take it from, so
|
||||||
|
// leaving it out has to fail rather than write under an empty host.
|
||||||
|
const res = await app.request('/games/download-state', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { ...adminHeaders(), 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ steamAppId: 440, status: 'ready' })
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
const body = (await res.json()) as any;
|
||||||
|
expect(body.code).toBe('missing_required_field');
|
||||||
|
expect(body.param).toBe('hostId');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Access tokens', () => {
|
||||||
|
test('creating a token requires authentication', async () => {
|
||||||
|
const res = await app.request('/access-token', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ name: 'living-room-box' })
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the admin token cannot mint a token for anyone', async () => {
|
||||||
|
// This is the boundary that makes admin safe to hand out for tooling:
|
||||||
|
// it reads and writes API data but cannot *become* a user. Minting a
|
||||||
|
// PAT on someone's behalf would erase exactly that.
|
||||||
|
const res = await app.request('/access-token', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { ...adminHeaders(), 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ name: 'living-room-box' })
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
const body = (await res.json()) as any;
|
||||||
|
expect(body.message).toContain('user session');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a token needs a name', async () => {
|
||||||
|
const res = await app.request('/access-token', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { ...adminHeaders(), 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ name: '' })
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('expiry is capped at a year', async () => {
|
||||||
|
const res = await app.request('/access-token', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { ...adminHeaders(), 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ name: 'box', expiresInDays: 4000 })
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
const body = (await res.json()) as any;
|
||||||
|
expect(body.type).toBe('validation');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('teamId accepts null to force a token scoped to the user alone', async () => {
|
||||||
|
// Team scope is the default and is *broader* than user scope, so there
|
||||||
|
// has to be an explicit way to ask for the narrow one. Null is it;
|
||||||
|
// omitting the field means "take the default", which is not the same.
|
||||||
|
const res = await app.request('/access-token', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { ...adminHeaders(), 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ name: 'box', teamId: null })
|
||||||
|
});
|
||||||
|
// Admin is refused at the handler, but only after validation — so a
|
||||||
|
// 403 here proves null passed the schema rather than being rejected.
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('revoking someone else’s token requires authentication', async () => {
|
||||||
|
const res = await app.request('/access-token/pat_whatever', { method: 'DELETE' });
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an unknown access token is unauthenticated, not a server error', async () => {
|
||||||
|
// A `pat_` prefix routes to the database rather than JWT verification.
|
||||||
|
// A miss there must read as "not signed in", the same as a bad JWT.
|
||||||
|
const res = await app.request('/games', {
|
||||||
|
headers: { authorization: 'Bearer pat_nosuchtokenvalue' }
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
const body = (await res.json()) as any;
|
||||||
|
expect(body.type).toBe('authentication');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Box access', () => {
|
||||||
|
test('rescoping a machine requires authentication', async () => {
|
||||||
|
const res = await app.request('/machine/mch_whatever', {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ teamId: null })
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the admin token cannot rescope a machine', async () => {
|
||||||
|
// Rescoping is an owner action and the query is scoped to a user id;
|
||||||
|
// admin has none, so it must be refused rather than 500 later.
|
||||||
|
const res = await app.request('/machine/mch_whatever', {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { ...adminHeaders(), 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ teamId: null })
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
const body = (await res.json()) as any;
|
||||||
|
expect(body.message).toContain('user session');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('teamId is required on the body, and may be null', async () => {
|
||||||
|
// Null is "make it mine alone" — a different thing from omitting the
|
||||||
|
// field, which would leave the scope ambiguous.
|
||||||
|
const missing = await app.request('/machine/mch_whatever', {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { ...adminHeaders(), 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({})
|
||||||
|
});
|
||||||
|
expect(missing.status).toBe(400);
|
||||||
|
|
||||||
|
const explicitNull = await app.request('/machine/mch_whatever', {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { ...adminHeaders(), 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ teamId: null })
|
||||||
|
});
|
||||||
|
// Past validation, refused at the handler for being admin.
|
||||||
|
expect(explicitNull.status).toBe(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('entitlement requires machine credentials, not a user session', async () => {
|
||||||
|
// The machine is taken from its credentials, never the query, so a box
|
||||||
|
// cannot ask about another box.
|
||||||
|
const res = await app.request('/machine/entitlement?userId=usr_x', {
|
||||||
|
headers: adminHeaders()
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
const body = (await res.json()) as any;
|
||||||
|
expect(body.message).toContain('Machine credentials');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('entitlement needs a userId to answer about', async () => {
|
||||||
|
const res = await app.request('/machine/entitlement');
|
||||||
|
// machineOnly refuses before validation; either way it does not answer.
|
||||||
|
expect([400, 403]).toContain(res.status);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Machine registration', () => {
|
||||||
|
test('registering a machine requires authentication', async () => {
|
||||||
|
const res = await app.request('/machine/register', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ label: 'living-room-box' })
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the admin token cannot register a machine', async () => {
|
||||||
|
// Registering is an act of ownership and the resulting row references a
|
||||||
|
// user. Admin is authenticated but owns nothing, so it must be refused
|
||||||
|
// here rather than fail later on a null owner.
|
||||||
|
const res = await app.request('/machine/register', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { ...adminHeaders(), 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ label: 'living-room-box' })
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
const body = (await res.json()) as any;
|
||||||
|
expect(body.message).toContain('user session');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('registering a machine requires a label', async () => {
|
||||||
|
const res = await app.request('/machine/register', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { ...adminHeaders(), 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ label: '' })
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
const body = (await res.json()) as any;
|
||||||
|
expect(body.type).toBe('validation');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('describing yourself requires machine credentials', async () => {
|
||||||
|
const res = await app.request('/machine/me', { headers: adminHeaders() });
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
const body = (await res.json()) as any;
|
||||||
|
expect(body.message).toContain('Machine credentials');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Steam routes', () => {
|
||||||
|
test('POST /steam/link requires auth', async () => {
|
||||||
|
const res = await app.request('/steam/link', { method: 'POST' });
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('POST /steam/link validates steamId', async () => {
|
||||||
|
const res = await app.request('/steam/link', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
...adminHeaders(),
|
||||||
|
'content-type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({})
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('User routes', () => {
|
||||||
|
test('GET /user requires auth', async () => {
|
||||||
|
const res = await app.request('/user');
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Library routes', () => {
|
||||||
|
test('GET /library requires auth', async () => {
|
||||||
|
const res = await app.request('/library');
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Pairing code routes', () => {
|
||||||
|
test('POST /pairing-code requires auth', async () => {
|
||||||
|
// Generating a code says "this key is also me", so it can only be done
|
||||||
|
// from a session that already is that user.
|
||||||
|
const res = await app.request('/pairing-code', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({})
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('POST /pairing-code/claim rejects an unauthenticated caller', async () => {
|
||||||
|
// Claiming is done for a device with no identity yet, so it carries the
|
||||||
|
// admin token rather than a user session. Without it, no.
|
||||||
|
const res = await app.request('/pairing-code/claim', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ code: 'NESSH-7F2Q', fingerprint: 'aa:bb' })
|
||||||
|
});
|
||||||
|
expect([401, 403]).toContain(res.status);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('POST /pairing-code/claim requires both a code and a fingerprint', async () => {
|
||||||
|
for (const body of [{}, { code: 'NESSH-7F2Q' }, { fingerprint: 'aa:bb' }]) {
|
||||||
|
// eslint-disable-next-line
|
||||||
|
const res = await app.request('/pairing-code/claim', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { ...adminHeaders(), 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify(body)
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('POST /pairing-code/claim rejects an empty code', async () => {
|
||||||
|
// An empty string must not be treated as "any code".
|
||||||
|
const res = await app.request('/pairing-code/claim', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { ...adminHeaders(), 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ code: '', fingerprint: 'aa:bb' })
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('POST /pairing-code caps how long a code stays valid', async () => {
|
||||||
|
// Short-lived by design; a long-lived code is a shared password.
|
||||||
|
const res = await app.request('/pairing-code', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { ...adminHeaders(), 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ ttlMinutes: 60 * 24 })
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
});
|
||||||
16
apps/api/test/setup.ts
Normal file
16
apps/api/test/setup.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { beforeEach } from 'bun:test';
|
||||||
|
|
||||||
|
import { Env } from '@nestri/core/env';
|
||||||
|
|
||||||
|
const TEST_ADMIN_SECRET = 'test-admin-secret-42';
|
||||||
|
const TEST_FRONTEND_URL = 'http://localhost:5173';
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
Env.init({
|
||||||
|
NODE_ENV: 'test',
|
||||||
|
ADMIN_SHARED_SECRET: TEST_ADMIN_SECRET,
|
||||||
|
FRONTEND_URL: TEST_FRONTEND_URL
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
export { TEST_ADMIN_SECRET, TEST_FRONTEND_URL };
|
||||||
13
apps/api/tsconfig.json
Normal file
13
apps/api/tsconfig.json
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ESNext",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"strict": true,
|
||||||
|
"lib": ["esnext"],
|
||||||
|
"types": ["@cloudflare/workers-types", "node", "@types/bun"],
|
||||||
|
"noEmit": true,
|
||||||
|
"skipLibCheck": true
|
||||||
|
}
|
||||||
|
}
|
||||||
20
apps/auth/package.json
Normal file
20
apps/auth/package.json
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"name": "auth",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@nestri/auth": "workspace:",
|
||||||
|
"@nestri/core": "workspace:"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@cloudflare/workers-types": "catalog:",
|
||||||
|
"@tsconfig/node22": "catalog:",
|
||||||
|
"@types/bun": "catalog:",
|
||||||
|
"@types/node": "catalog:"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"typescript": "catalog:"
|
||||||
|
}
|
||||||
|
}
|
||||||
112
apps/auth/src/index.ts
Normal file
112
apps/auth/src/index.ts
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
import type { Hyperdrive, KVNamespace } from '@cloudflare/workers-types';
|
||||||
|
import { issuer } from '@nestri/auth/index';
|
||||||
|
import { SshProvider } from '@nestri/auth/provider/ssh';
|
||||||
|
import { SteamProvider } from '@nestri/auth/provider/steam';
|
||||||
|
import { CloudflareStorage } from '@nestri/auth/storage/cloudflare';
|
||||||
|
import { subjects } from '@nestri/core/auth/subjects';
|
||||||
|
import { Database } from '@nestri/core/db/index';
|
||||||
|
import { Env } from '@nestri/core/env';
|
||||||
|
import { Identifier } from '@nestri/core/id';
|
||||||
|
import { Steam } from '@nestri/core/steam/index';
|
||||||
|
import { User } from '@nestri/core/user/index';
|
||||||
|
import { LinkedAccount } from '@nestri/core/user/linked-account';
|
||||||
|
|
||||||
|
type Env = {
|
||||||
|
AuthStorage: KVNamespace;
|
||||||
|
HYPERDRIVE: Hyperdrive;
|
||||||
|
STEAM_API_KEY: string;
|
||||||
|
SSH_AUTH_KEY: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default {
|
||||||
|
async fetch(request: Request, env: Env, ctx: ExecutionContext) {
|
||||||
|
Env.init(env as unknown as Record<string, unknown>);
|
||||||
|
const inner = issuer({
|
||||||
|
subjects,
|
||||||
|
storage: CloudflareStorage({
|
||||||
|
namespace: env.AuthStorage
|
||||||
|
}),
|
||||||
|
providers: {
|
||||||
|
steam: SteamProvider(),
|
||||||
|
ssh: SshProvider({ sshAuthKey: env.SSH_AUTH_KEY })
|
||||||
|
},
|
||||||
|
async success(context, response) {
|
||||||
|
if (response.provider === 'steam') {
|
||||||
|
const { steamid } = response;
|
||||||
|
const profileUrl = new URL(
|
||||||
|
'https://api.steampowered.com/ISteamUser/GetPlayerSummaries/v0002/'
|
||||||
|
);
|
||||||
|
profileUrl.searchParams.set('key', env.STEAM_API_KEY);
|
||||||
|
profileUrl.searchParams.set('steamids', steamid);
|
||||||
|
|
||||||
|
const profileRes = await fetch(profileUrl.toString());
|
||||||
|
const profileData = (await profileRes.json()) as {
|
||||||
|
response?: { players?: Array<Record<string, unknown>> };
|
||||||
|
};
|
||||||
|
|
||||||
|
const player = profileData?.response?.players?.[0] as any;
|
||||||
|
const personaname: string = player?.personaname ?? 'Player';
|
||||||
|
const avatarfull: string = player?.avatarfull;
|
||||||
|
|
||||||
|
const { userID, linkedAccountID } = await Database.transaction(async () => {
|
||||||
|
const existing = await LinkedAccount.findByProvider({
|
||||||
|
provider: 'steam',
|
||||||
|
providerAccountId: steamid
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
const user = await User.fromID(existing.userId);
|
||||||
|
if (!user) throw new Error('User not found for linked account');
|
||||||
|
return { userID: user.id, linkedAccountID: existing.id };
|
||||||
|
}
|
||||||
|
|
||||||
|
const newUserID = Identifier.ascending('user');
|
||||||
|
await User.create({
|
||||||
|
id: newUserID,
|
||||||
|
name: personaname,
|
||||||
|
email: undefined,
|
||||||
|
emailVerified: false,
|
||||||
|
image: avatarfull ?? null
|
||||||
|
});
|
||||||
|
|
||||||
|
const newLinkedAccountID = Identifier.ascending('linkedAccount');
|
||||||
|
await LinkedAccount.create({
|
||||||
|
id: newLinkedAccountID,
|
||||||
|
userId: newUserID,
|
||||||
|
provider: 'steam',
|
||||||
|
providerAccountId: steamid,
|
||||||
|
profile: player ?? {}
|
||||||
|
});
|
||||||
|
|
||||||
|
return { userID: newUserID, linkedAccountID: newLinkedAccountID };
|
||||||
|
});
|
||||||
|
|
||||||
|
return context.subject('user', {
|
||||||
|
userID,
|
||||||
|
linkedAccountID
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.provider === 'ssh') {
|
||||||
|
const { fingerprint, steamId, username, profile } = response;
|
||||||
|
const { userID, linkedAccountID } = await Steam.resolveSshIdentity({
|
||||||
|
fingerprint,
|
||||||
|
steamId,
|
||||||
|
username,
|
||||||
|
profile
|
||||||
|
});
|
||||||
|
|
||||||
|
return context.subject('user', {
|
||||||
|
userID,
|
||||||
|
linkedAccountID,
|
||||||
|
fingerprint
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('Unknown provider');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return inner.fetch(request, env, ctx);
|
||||||
|
}
|
||||||
|
};
|
||||||
227
apps/auth/test/worker.test.ts
Normal file
227
apps/auth/test/worker.test.ts
Normal file
@@ -0,0 +1,227 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test';
|
||||||
|
|
||||||
|
import { createClient } from '@nestri/auth/client';
|
||||||
|
import { issuer } from '@nestri/auth/index';
|
||||||
|
import { SshProvider } from '@nestri/auth/provider/ssh';
|
||||||
|
import { SteamProvider } from '@nestri/auth/provider/steam';
|
||||||
|
import { MemoryStorage } from '@nestri/auth/storage/memory';
|
||||||
|
import { subjects } from '@nestri/core/auth/subjects';
|
||||||
|
|
||||||
|
const storage = MemoryStorage();
|
||||||
|
|
||||||
|
const auth = issuer({
|
||||||
|
subjects,
|
||||||
|
storage,
|
||||||
|
allow: async () => true,
|
||||||
|
providers: {
|
||||||
|
steam: SteamProvider(),
|
||||||
|
ssh: SshProvider({ sshAuthKey: 'test-ssh-key' })
|
||||||
|
},
|
||||||
|
async success(context, response) {
|
||||||
|
if (response.provider === 'steam') {
|
||||||
|
return context.subject('user', {
|
||||||
|
userID: 'usr_test123',
|
||||||
|
linkedAccountID: 'lac_test456'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (response.provider === 'ssh') {
|
||||||
|
return context.subject('user', {
|
||||||
|
userID: 'usr_test123',
|
||||||
|
linkedAccountID: 'lac_test456',
|
||||||
|
fingerprint: response.fingerprint
|
||||||
|
});
|
||||||
|
}
|
||||||
|
throw new Error('unknown provider');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
globalThis.fetch = mock(async (input: string | URL | Request, _init?: RequestInit) => {
|
||||||
|
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
|
||||||
|
|
||||||
|
if (url.includes('steamcommunity.com/openid/login')) {
|
||||||
|
return new Response('ns:http://specs.openid.net/auth/2.0\nis_valid:true\n', { status: 200 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.includes('api.steampowered.com')) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
response: {
|
||||||
|
players: [
|
||||||
|
{
|
||||||
|
personaname: 'TestPlayer',
|
||||||
|
avatarfull:
|
||||||
|
'https://steamcdn-a.akamaihd.net/steamcommunity/public/images/avatars/fe/fef49e7fa7e1997310d705b2a6158ff8dc1cdfeb.jpg',
|
||||||
|
steamid: '76561197960287956'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
{ status: 200 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response('not found', { status: 404 });
|
||||||
|
}) as unknown as typeof fetch;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
globalThis.fetch = fetch;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Steam auth flow', () => {
|
||||||
|
test('authorize redirects to Steam OpenID', async () => {
|
||||||
|
const response = await auth.request('https://auth.internal/steam/authorize');
|
||||||
|
expect(response.status).toBe(302);
|
||||||
|
expect(response.headers.get('location')).toMatch(/steamcommunity\.com\/openid/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('full code flow and token verification', async () => {
|
||||||
|
const client = createClient({
|
||||||
|
issuer: 'https://auth.internal',
|
||||||
|
clientID: 'api',
|
||||||
|
fetch: (input: any, init: any) => Promise.resolve(auth.request(input, init))
|
||||||
|
});
|
||||||
|
|
||||||
|
const { challenge, url } = await client.authorize(
|
||||||
|
'https://client.example.com/callback',
|
||||||
|
'code',
|
||||||
|
{ pkce: true, provider: 'steam' }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Step 1: hit the authorize URL → redirects to Steam OpenID
|
||||||
|
const authResponse = await auth.request(url);
|
||||||
|
expect(authResponse.status).toBe(302);
|
||||||
|
const cookie = authResponse.headers.get('set-cookie')!;
|
||||||
|
expect(cookie).toBeDefined();
|
||||||
|
|
||||||
|
// Step 2: simulate Steam redirecting back to our callback with valid OpenID params
|
||||||
|
const callbackUrl =
|
||||||
|
'https://auth.internal/steam/callback?' +
|
||||||
|
'openid.ns=http%3A%2F%2Fspecs.openid.net%2Fauth%2F2.0&' +
|
||||||
|
'openid.mode=id_res&' +
|
||||||
|
'openid.return_to=https%3A%2F%2Fauth.internal%2Fsteam%2Fcallback&' +
|
||||||
|
'openid.claimed_id=https%3A%2F%2Fsteamcommunity.com%2Fopenid%2Fid%2F76561197960287956&' +
|
||||||
|
'openid.identity=https%3A%2F%2Fsteamcommunity.com%2Fopenid%2Fid%2F76561197960287956';
|
||||||
|
|
||||||
|
const callbackResponse = await auth.request(callbackUrl, {
|
||||||
|
headers: { cookie }
|
||||||
|
});
|
||||||
|
expect(callbackResponse.status).toBe(302);
|
||||||
|
|
||||||
|
const location = new URL(callbackResponse.headers.get('location')!);
|
||||||
|
const code = location.searchParams.get('code');
|
||||||
|
expect(code).not.toBeNull();
|
||||||
|
|
||||||
|
const exchanged = await client.exchange(
|
||||||
|
code!,
|
||||||
|
'https://client.example.com/callback',
|
||||||
|
challenge.verifier
|
||||||
|
);
|
||||||
|
if (exchanged.err) throw exchanged.err;
|
||||||
|
const tokens = exchanged.tokens!;
|
||||||
|
|
||||||
|
expect(tokens.access).toBeString();
|
||||||
|
expect(tokens.refresh).toBeString();
|
||||||
|
|
||||||
|
const verified = await client.verify(subjects, tokens.access);
|
||||||
|
if (verified.err) throw verified.err;
|
||||||
|
expect(verified.subject).toEqual({
|
||||||
|
type: 'user',
|
||||||
|
properties: {
|
||||||
|
userID: 'usr_test123',
|
||||||
|
linkedAccountID: 'lac_test456'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('SSH login', () => {
|
||||||
|
test('valid login returns tokens', async () => {
|
||||||
|
const loginResponse = await auth.request('https://auth.internal/ssh/login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: 'Bearer test-ssh-key'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
fingerprint: 'SHA256:abc123',
|
||||||
|
steamId: '76561198012345678'
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(loginResponse.status).toBe(200);
|
||||||
|
const body: any = await loginResponse.json();
|
||||||
|
expect(body.accessToken).toBeString();
|
||||||
|
expect(body.refreshToken).toBeString();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('invalid auth key returns 401', async () => {
|
||||||
|
const response = await auth.request('https://auth.internal/ssh/login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: 'Bearer wrong-key'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
fingerprint: 'SHA256:abc123',
|
||||||
|
steamId: '76561198012345678'
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(401);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('User info', () => {
|
||||||
|
async function getTokens() {
|
||||||
|
const client = createClient({
|
||||||
|
issuer: 'https://auth.internal',
|
||||||
|
clientID: 'api',
|
||||||
|
fetch: (input: any, init: any) => Promise.resolve(auth.request(input, init))
|
||||||
|
});
|
||||||
|
|
||||||
|
const { challenge, url } = await client.authorize(
|
||||||
|
'https://client.example.com/callback',
|
||||||
|
'code',
|
||||||
|
{ pkce: true, provider: 'steam' }
|
||||||
|
);
|
||||||
|
|
||||||
|
const authResponse = await auth.request(url);
|
||||||
|
const cookie = authResponse.headers.get('set-cookie')!;
|
||||||
|
|
||||||
|
const callbackUrl =
|
||||||
|
'https://auth.internal/steam/callback?' +
|
||||||
|
'openid.ns=http%3A%2F%2Fspecs.openid.net%2Fauth%2F2.0&' +
|
||||||
|
'openid.mode=id_res&' +
|
||||||
|
'openid.return_to=https%3A%2F%2Fauth.internal%2Fsteam%2Fcallback&' +
|
||||||
|
'openid.claimed_id=https%3A%2F%2Fsteamcommunity.com%2Fopenid%2Fid%2F76561197960287956&' +
|
||||||
|
'openid.identity=https%3A%2F%2Fsteamcommunity.com%2Fopenid%2Fid%2F76561197960287956';
|
||||||
|
|
||||||
|
const callbackResponse = await auth.request(callbackUrl, { headers: { cookie } });
|
||||||
|
const location = new URL(callbackResponse.headers.get('location')!);
|
||||||
|
const code = location.searchParams.get('code');
|
||||||
|
const exchanged = await client.exchange(
|
||||||
|
code!,
|
||||||
|
'https://client.example.com/callback',
|
||||||
|
challenge.verifier
|
||||||
|
);
|
||||||
|
if (exchanged.err) throw exchanged.err;
|
||||||
|
return { client, tokens: exchanged.tokens! };
|
||||||
|
}
|
||||||
|
|
||||||
|
test('returns subject properties for valid access token', async () => {
|
||||||
|
const { tokens } = await getTokens();
|
||||||
|
|
||||||
|
const infoRes = await auth.request('https://auth.internal/userinfo', {
|
||||||
|
headers: { Authorization: `Bearer ${tokens.access}` }
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(infoRes.status).toBe(200);
|
||||||
|
const userinfo = await infoRes.json();
|
||||||
|
expect(userinfo).toMatchObject({
|
||||||
|
userID: 'usr_test123',
|
||||||
|
linkedAccountID: 'lac_test456'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
11
apps/auth/tsconfig.json
Normal file
11
apps/auth/tsconfig.json
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/tsconfig",
|
||||||
|
"extends": "@tsconfig/node22/tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"jsx": "preserve",
|
||||||
|
"jsxImportSource": "react",
|
||||||
|
"types": ["@cloudflare/workers-types", "node", "bun"]
|
||||||
|
}
|
||||||
|
}
|
||||||
17
docker-compose.yml
Normal file
17
docker-compose.yml
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: docker.io/postgres:18-alpine
|
||||||
|
container_name: nestri_postgres
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: postgres # Matches: user: 'postgres'
|
||||||
|
POSTGRES_PASSWORD: postgres # Matches: password: 'postgres'
|
||||||
|
POSTGRES_DB: nestri # Matches: database: 'nestri'
|
||||||
|
ports:
|
||||||
|
- '5432:5432' # Matches: port: 5432
|
||||||
|
volumes:
|
||||||
|
- nestri_data:/var/lib/postgresql
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
nestri_data: # Keeps your data safe when container restarts
|
||||||
29
oxlintrc.json
Normal file
29
oxlintrc.json
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://raw.githubusercontent.com/oxc-project/oxc/refs/heads/main/npm/oxlint/configuration_schema.json",
|
||||||
|
"plugins": ["typescript", "unicorn", "oxc", "import", "jsdoc", "node", "promise", "vitest"],
|
||||||
|
"categories": {
|
||||||
|
"correctness": "error",
|
||||||
|
"perf": "error"
|
||||||
|
},
|
||||||
|
"rules": {
|
||||||
|
"no-console": "error",
|
||||||
|
"curly": ["error", "multi-line"],
|
||||||
|
"prefer-const": ["off", { "destructuring": "all" }],
|
||||||
|
"prefer-destructuring": [
|
||||||
|
"error",
|
||||||
|
{
|
||||||
|
"VariableDeclarator": { "array": false, "object": true }
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"unicode-bom": ["error", "never"],
|
||||||
|
"eslint/no-unassigned-vars": "off",
|
||||||
|
"typescript/consistent-indexed-object-style": ["error", "record"],
|
||||||
|
"typescript/ban-ts-comment": ["error", { "ts-expect-error": "allow-with-description" }],
|
||||||
|
"vitest/require-mock-type-parameters": "off",
|
||||||
|
"vitest/prefer-snapshot-hint": "off"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"typeAware": true,
|
||||||
|
"typeCheck": true
|
||||||
|
}
|
||||||
|
}
|
||||||
2
packages/auth/bunfig.toml
Normal file
2
packages/auth/bunfig.toml
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
[test]
|
||||||
|
root = "./test"
|
||||||
41
packages/auth/package.json
Normal file
41
packages/auth/package.json
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
{
|
||||||
|
"name": "@nestri/auth",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"files": [
|
||||||
|
"src"
|
||||||
|
],
|
||||||
|
"type": "module",
|
||||||
|
"sideEffects": false,
|
||||||
|
"exports": {
|
||||||
|
"./*": {
|
||||||
|
"types": "./src/*.ts",
|
||||||
|
"import": "./src/*.ts"
|
||||||
|
},
|
||||||
|
"./**/*": {
|
||||||
|
"types": "./src/**/*.ts",
|
||||||
|
"import": "./src/**/*.ts"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"build": "bun run script/build.ts",
|
||||||
|
"test": "bun test"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@standard-schema/spec": "1.0.0-beta.3",
|
||||||
|
"aws4fetch": "1.0.20",
|
||||||
|
"jose": "5.9.6"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@cloudflare/workers-types": "catalog:",
|
||||||
|
"@tsconfig/node22": "catalog:",
|
||||||
|
"@types/node": "catalog:",
|
||||||
|
"arctic": "2.2.2",
|
||||||
|
"hono": "catalog:",
|
||||||
|
"typescript": "catalog:",
|
||||||
|
"valibot": "1.0.0-beta.15"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"arctic": "^2.2.2",
|
||||||
|
"hono": "catalog:"
|
||||||
|
}
|
||||||
|
}
|
||||||
23
packages/auth/script/build.ts
Normal file
23
packages/auth/script/build.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { Glob, $ } from 'bun';
|
||||||
|
|
||||||
|
import pkg from '../package.json';
|
||||||
|
|
||||||
|
await $`rm -rf dist`;
|
||||||
|
const files = new Glob('./src/**/*.{ts,tsx}').scan();
|
||||||
|
for await (const file of files) {
|
||||||
|
await Bun.build({
|
||||||
|
format: 'esm',
|
||||||
|
outdir: 'dist/esm',
|
||||||
|
external: ['*'],
|
||||||
|
root: 'src',
|
||||||
|
entrypoints: [file]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await Bun.build({
|
||||||
|
format: 'esm',
|
||||||
|
outdir: 'dist/esm',
|
||||||
|
external: [...Object.keys(pkg.dependencies), ...Object.keys(pkg.peerDependencies)],
|
||||||
|
root: 'src',
|
||||||
|
entrypoints: ['./src/ui/base.tsx']
|
||||||
|
});
|
||||||
|
await $`tsc --outDir dist/types --declaration --emitDeclarationOnly --declarationMap`;
|
||||||
729
packages/auth/src/client.ts
Normal file
729
packages/auth/src/client.ts
Normal file
@@ -0,0 +1,729 @@
|
|||||||
|
import type { v1 } from '@standard-schema/spec';
|
||||||
|
/**
|
||||||
|
* Use the OpenAuth client kick off your OAuth flows, exchange tokens, refresh tokens,
|
||||||
|
* and verify tokens.
|
||||||
|
*
|
||||||
|
* First, create a client.
|
||||||
|
*
|
||||||
|
* ```ts title="client.ts"
|
||||||
|
* import { createClient } from "@openauthjs/openauth/client"
|
||||||
|
*
|
||||||
|
* const client = createClient({
|
||||||
|
* clientID: "my-client",
|
||||||
|
* issuer: "https://auth.myserver.com"
|
||||||
|
* })
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* Kick off the OAuth flow by calling `authorize`.
|
||||||
|
*
|
||||||
|
* ```ts
|
||||||
|
* const redirect_uri = "https://myserver.com/callback"
|
||||||
|
*
|
||||||
|
* const { url } = await client.authorize(
|
||||||
|
* redirect_uri,
|
||||||
|
* "code"
|
||||||
|
* )
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* When the user completes the flow, `exchange` the code for tokens.
|
||||||
|
*
|
||||||
|
* ```ts
|
||||||
|
* const tokens = await client.exchange(query.get("code"), redirect_uri)
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* And `verify` the tokens.
|
||||||
|
*
|
||||||
|
* ```ts
|
||||||
|
* const verified = await client.verify(subjects, tokens.access)
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @packageDocumentation
|
||||||
|
*/
|
||||||
|
import { createLocalJWKSet, errors, JSONWebKeySet, jwtVerify, decodeJwt } from 'jose';
|
||||||
|
|
||||||
|
import {
|
||||||
|
InvalidAccessTokenError,
|
||||||
|
InvalidAuthorizationCodeError,
|
||||||
|
InvalidRefreshTokenError,
|
||||||
|
InvalidSubjectError
|
||||||
|
} from './error.js';
|
||||||
|
import { generatePKCE } from './pkce.js';
|
||||||
|
import { SubjectSchema } from './subject.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The well-known information for an OAuth 2.0 authorization server.
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
export interface WellKnown {
|
||||||
|
/**
|
||||||
|
* The URI to the JWKS endpoint.
|
||||||
|
*/
|
||||||
|
jwks_uri: string;
|
||||||
|
/**
|
||||||
|
* The URI to the token endpoint.
|
||||||
|
*/
|
||||||
|
token_endpoint: string;
|
||||||
|
/**
|
||||||
|
* The URI to the authorization endpoint.
|
||||||
|
*/
|
||||||
|
authorization_endpoint: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The tokens returned by the auth server.
|
||||||
|
*/
|
||||||
|
export interface Tokens {
|
||||||
|
/**
|
||||||
|
* The access token.
|
||||||
|
*/
|
||||||
|
access: string;
|
||||||
|
/**
|
||||||
|
* The refresh token.
|
||||||
|
*/
|
||||||
|
refresh: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The number of seconds until the access token expires.
|
||||||
|
*/
|
||||||
|
expiresIn: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ResponseLike {
|
||||||
|
json(): Promise<unknown>;
|
||||||
|
ok: Response['ok'];
|
||||||
|
}
|
||||||
|
type FetchLike = (...args: any[]) => Promise<ResponseLike>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The challenge that you can use to verify the code.
|
||||||
|
*/
|
||||||
|
export type Challenge = {
|
||||||
|
/**
|
||||||
|
* The state that was sent to the redirect URI.
|
||||||
|
*/
|
||||||
|
state: string;
|
||||||
|
/**
|
||||||
|
* The verifier that was sent to the redirect URI.
|
||||||
|
*/
|
||||||
|
verifier?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configure the client.
|
||||||
|
*/
|
||||||
|
export interface ClientInput {
|
||||||
|
/**
|
||||||
|
* The client ID. This is just a string to identify your app.
|
||||||
|
*
|
||||||
|
* If you have a web app and a mobile app, you want to use different client IDs both.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* {
|
||||||
|
* clientID: "my-client"
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
clientID: string;
|
||||||
|
/**
|
||||||
|
* The URL of your OpenAuth server.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* {
|
||||||
|
* issuer: "https://auth.myserver.com"
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
issuer?: string;
|
||||||
|
/**
|
||||||
|
* Optionally, override the internally used fetch function.
|
||||||
|
*
|
||||||
|
* This is useful if you are using a polyfilled fetch function in your application and you
|
||||||
|
* want the client to use it too.
|
||||||
|
*/
|
||||||
|
fetch?: FetchLike;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthorizeOptions {
|
||||||
|
/**
|
||||||
|
* Enable the PKCE flow. This is for SPA apps.
|
||||||
|
*
|
||||||
|
* ```ts
|
||||||
|
* {
|
||||||
|
* pkce: true
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @default false
|
||||||
|
*/
|
||||||
|
pkce?: boolean;
|
||||||
|
/**
|
||||||
|
* The provider you want to use for the OAuth flow.
|
||||||
|
*
|
||||||
|
* ```ts
|
||||||
|
* {
|
||||||
|
* provider: "google"
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* If no provider is specified, the user is directed to a page where they can select from the
|
||||||
|
* list of configured providers.
|
||||||
|
*
|
||||||
|
* If there's only one provider configured, the user will be redirected to that.
|
||||||
|
*/
|
||||||
|
provider?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthorizeResult {
|
||||||
|
/**
|
||||||
|
* The challenge that you can use to verify the code. This is for the PKCE flow for SPA apps.
|
||||||
|
*
|
||||||
|
* This is an object that you _stringify_ and store it in session storage.
|
||||||
|
*
|
||||||
|
* ```ts
|
||||||
|
* sessionStorage.setItem("challenge", JSON.stringify(challenge))
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
challenge: Challenge;
|
||||||
|
/**
|
||||||
|
* The URL to redirect the user to. This starts the OAuth flow.
|
||||||
|
*
|
||||||
|
* For example, for SPA apps.
|
||||||
|
*
|
||||||
|
* ```ts
|
||||||
|
* location.href = url
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returned when the exchange is successful.
|
||||||
|
*/
|
||||||
|
export interface ExchangeSuccess {
|
||||||
|
/**
|
||||||
|
* This is always `false` when the exchange is successful.
|
||||||
|
*/
|
||||||
|
err: false;
|
||||||
|
/**
|
||||||
|
* The access and refresh tokens.
|
||||||
|
*/
|
||||||
|
tokens: Tokens;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returned when the exchange fails.
|
||||||
|
*/
|
||||||
|
export interface ExchangeError {
|
||||||
|
/**
|
||||||
|
* The type of error that occurred. You can handle this by checking the type.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* import { InvalidAuthorizationCodeError } from "@openauthjs/openauth/error"
|
||||||
|
*
|
||||||
|
* console.log(err instanceof InvalidAuthorizationCodeError)
|
||||||
|
*```
|
||||||
|
*/
|
||||||
|
err: InvalidAuthorizationCodeError;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RefreshOptions {
|
||||||
|
/**
|
||||||
|
* Optionally, pass in the access token.
|
||||||
|
*/
|
||||||
|
access?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returned when the refresh is successful.
|
||||||
|
*/
|
||||||
|
export interface RefreshSuccess {
|
||||||
|
/**
|
||||||
|
* This is always `false` when the refresh is successful.
|
||||||
|
*/
|
||||||
|
err: false;
|
||||||
|
/**
|
||||||
|
* Returns the refreshed tokens only if they've been refreshed.
|
||||||
|
*
|
||||||
|
* If they are still valid, this will be `undefined`.
|
||||||
|
*/
|
||||||
|
tokens?: Tokens;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returned when the refresh fails.
|
||||||
|
*/
|
||||||
|
export interface RefreshError {
|
||||||
|
/**
|
||||||
|
* The type of error that occurred. You can handle this by checking the type.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* import { InvalidRefreshTokenError } from "@openauthjs/openauth/error"
|
||||||
|
*
|
||||||
|
* console.log(err instanceof InvalidRefreshTokenError)
|
||||||
|
*```
|
||||||
|
*/
|
||||||
|
err: InvalidRefreshTokenError | InvalidAccessTokenError;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VerifyOptions {
|
||||||
|
/**
|
||||||
|
* Optionally, pass in the refresh token.
|
||||||
|
*
|
||||||
|
* If passed in, this will automatically refresh the access token if it has expired.
|
||||||
|
*/
|
||||||
|
refresh?: string;
|
||||||
|
/**
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
issuer?: string;
|
||||||
|
/**
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
audience?: string;
|
||||||
|
/**
|
||||||
|
* Optionally, override the internally used fetch function.
|
||||||
|
*
|
||||||
|
* This is useful if you are using a polyfilled fetch function in your application and you
|
||||||
|
* want the client to use it too.
|
||||||
|
*/
|
||||||
|
fetch?: FetchLike;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VerifyResult<T extends SubjectSchema> {
|
||||||
|
/**
|
||||||
|
* This is always `undefined` when the verify is successful.
|
||||||
|
*/
|
||||||
|
err?: undefined;
|
||||||
|
/**
|
||||||
|
* Returns the refreshed tokens only if they’ve been refreshed.
|
||||||
|
*
|
||||||
|
* If they are still valid, this will be undefined.
|
||||||
|
*/
|
||||||
|
tokens?: Tokens;
|
||||||
|
/**
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
aud: string;
|
||||||
|
/**
|
||||||
|
* The decoded subjects from the access token.
|
||||||
|
*
|
||||||
|
* Has the same shape as the subjects you defined when creating the issuer.
|
||||||
|
*/
|
||||||
|
subject: {
|
||||||
|
[type in keyof T]: { type: type; properties: v1.InferOutput<T[type]> };
|
||||||
|
}[keyof T];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returned when the verify call fails.
|
||||||
|
*/
|
||||||
|
export interface VerifyError {
|
||||||
|
/**
|
||||||
|
* The type of error that occurred. You can handle this by checking the type.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* import { InvalidRefreshTokenError } from "@openauthjs/openauth/error"
|
||||||
|
*
|
||||||
|
* console.log(err instanceof InvalidRefreshTokenError)
|
||||||
|
*```
|
||||||
|
*/
|
||||||
|
err: InvalidRefreshTokenError | InvalidAccessTokenError;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An instance of the OpenAuth client contains the following methods.
|
||||||
|
*/
|
||||||
|
export interface Client {
|
||||||
|
/**
|
||||||
|
* Start the autorization flow. For example, in SSR sites.
|
||||||
|
*
|
||||||
|
* ```ts
|
||||||
|
* const { url } = await client.authorize(<redirect_uri>, "code")
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* This takes a redirect URI and the type of flow you want to use. The redirect URI is the
|
||||||
|
* location where the user will be redirected to after the flow is complete.
|
||||||
|
*
|
||||||
|
* Supports both the _code_ and _token_ flows. We recommend using the _code_ flow as it's more
|
||||||
|
* secure.
|
||||||
|
*
|
||||||
|
* :::tip
|
||||||
|
* This returns a URL to redirect the user to. This starts the OAuth flow.
|
||||||
|
* :::
|
||||||
|
*
|
||||||
|
* This returns a URL to the auth server. You can redirect the user to the URL to start the
|
||||||
|
* OAuth flow.
|
||||||
|
*
|
||||||
|
* For SPA apps, we recommend using the PKCE flow.
|
||||||
|
*
|
||||||
|
* ```ts {4}
|
||||||
|
* const { challenge, url } = await client.authorize(
|
||||||
|
* <redirect_uri>,
|
||||||
|
* "code",
|
||||||
|
* { pkce: true }
|
||||||
|
* )
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* This returns a redirect URL and a challenge that you need to use later to verify the code.
|
||||||
|
*/
|
||||||
|
authorize(
|
||||||
|
redirectURI: string,
|
||||||
|
response: 'code' | 'token',
|
||||||
|
opts?: AuthorizeOptions
|
||||||
|
): Promise<AuthorizeResult>;
|
||||||
|
/**
|
||||||
|
* Exchange the code for access and refresh tokens.
|
||||||
|
*
|
||||||
|
* ```ts
|
||||||
|
* const exchanged = await client.exchange(<code>, <redirect_uri>)
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* You call this after the user has been redirected back to your app after the OAuth flow.
|
||||||
|
*
|
||||||
|
* :::tip
|
||||||
|
* For SSR sites, the code is returned in the query parameter.
|
||||||
|
* :::
|
||||||
|
*
|
||||||
|
* So the code comes from the query parameter in the redirect URI. The redirect URI here is
|
||||||
|
* the one that you passed in to the `authorize` call when starting the flow.
|
||||||
|
*
|
||||||
|
* :::tip
|
||||||
|
* For SPA sites, the code is returned through the URL hash.
|
||||||
|
* :::
|
||||||
|
*
|
||||||
|
* If you used the PKCE flow for an SPA app, the code is returned as a part of the redirect URL
|
||||||
|
* hash.
|
||||||
|
*
|
||||||
|
* ```ts {4}
|
||||||
|
* const exchanged = await client.exchange(
|
||||||
|
* <code>,
|
||||||
|
* <redirect_uri>,
|
||||||
|
* <challenge.verifier>
|
||||||
|
* )
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* You also need to pass in the previously stored challenge verifier.
|
||||||
|
*
|
||||||
|
* This method returns the access and refresh tokens. Or if it fails, it returns an error that
|
||||||
|
* you can handle depending on the error.
|
||||||
|
*
|
||||||
|
* ```ts
|
||||||
|
* import { InvalidAuthorizationCodeError } from "@openauthjs/openauth/error"
|
||||||
|
*
|
||||||
|
* if (exchanged.err) {
|
||||||
|
* if (exchanged.err instanceof InvalidAuthorizationCodeError) {
|
||||||
|
* // handle invalid code error
|
||||||
|
* }
|
||||||
|
* else {
|
||||||
|
* // handle other errors
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* const { access, refresh } = exchanged.tokens
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
exchange(
|
||||||
|
code: string,
|
||||||
|
redirectURI: string,
|
||||||
|
verifier?: string
|
||||||
|
): Promise<ExchangeSuccess | ExchangeError>;
|
||||||
|
/**
|
||||||
|
* Refreshes the tokens if they have expired. This is used in an SPA app to maintain the
|
||||||
|
* session, without logging the user out.
|
||||||
|
*
|
||||||
|
* ```ts
|
||||||
|
* const next = await client.refresh(<refresh_token>)
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* Can optionally take the access token as well. If passed in, this will skip the refresh
|
||||||
|
* if the access token is still valid.
|
||||||
|
*
|
||||||
|
* ```ts
|
||||||
|
* const next = await client.refresh(<refresh_token>, { access: <access_token> })
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* This returns the refreshed tokens only if they've been refreshed.
|
||||||
|
*
|
||||||
|
* ```ts
|
||||||
|
* if (!next.err) {
|
||||||
|
* // tokens are still valid
|
||||||
|
* }
|
||||||
|
* if (next.tokens) {
|
||||||
|
* const { access, refresh } = next.tokens
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* Or if it fails, it returns an error that you can handle depending on the error.
|
||||||
|
*
|
||||||
|
* ```ts
|
||||||
|
* import { InvalidRefreshTokenError } from "@openauthjs/openauth/error"
|
||||||
|
*
|
||||||
|
* if (next.err) {
|
||||||
|
* if (next.err instanceof InvalidRefreshTokenError) {
|
||||||
|
* // handle invalid refresh token error
|
||||||
|
* }
|
||||||
|
* else {
|
||||||
|
* // handle other errors
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
refresh(refresh: string, opts?: RefreshOptions): Promise<RefreshSuccess | RefreshError>;
|
||||||
|
/**
|
||||||
|
* Verify the token in the incoming request.
|
||||||
|
*
|
||||||
|
* This is typically used for SSR sites where the token is stored in an HTTP only cookie. And
|
||||||
|
* is passed to the server on every request.
|
||||||
|
*
|
||||||
|
* ```ts
|
||||||
|
* const verified = await client.verify(<subjects>, <token>)
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* This takes the subjects that you had previously defined when creating the issuer.
|
||||||
|
*
|
||||||
|
* :::tip
|
||||||
|
* If the refresh token is passed in, it'll automatically refresh the access token.
|
||||||
|
* :::
|
||||||
|
*
|
||||||
|
* This can optionally take the refresh token as well. If passed in, it'll automatically
|
||||||
|
* refresh the access token if it has expired.
|
||||||
|
*
|
||||||
|
* ```ts
|
||||||
|
* const verified = await client.verify(<subjects>, <token>, { refresh: <refresh_token> })
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* This returns the decoded subjects from the access token. And the tokens if they've been
|
||||||
|
* refreshed.
|
||||||
|
*
|
||||||
|
* ```ts
|
||||||
|
* // based on the subjects you defined earlier
|
||||||
|
* console.log(verified.subject.properties.userID)
|
||||||
|
*
|
||||||
|
* if (verified.tokens) {
|
||||||
|
* const { access, refresh } = verified.tokens
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* Or if it fails, it returns an error that you can handle depending on the error.
|
||||||
|
*
|
||||||
|
* ```ts
|
||||||
|
* import { InvalidRefreshTokenError } from "@openauthjs/openauth/error"
|
||||||
|
*
|
||||||
|
* if (verified.err) {
|
||||||
|
* if (verified.err instanceof InvalidRefreshTokenError) {
|
||||||
|
* // handle invalid refresh token error
|
||||||
|
* }
|
||||||
|
* else {
|
||||||
|
* // handle other errors
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
verify<T extends SubjectSchema>(
|
||||||
|
subjects: T,
|
||||||
|
token: string,
|
||||||
|
options?: VerifyOptions
|
||||||
|
): Promise<VerifyResult<T> | VerifyError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create an OpenAuth client.
|
||||||
|
*
|
||||||
|
* @param input - Configure the client.
|
||||||
|
*/
|
||||||
|
export function createClient(input: ClientInput): Client {
|
||||||
|
const jwksCache = new Map<string, ReturnType<typeof createLocalJWKSet>>();
|
||||||
|
const issuerCache = new Map<string, WellKnown>();
|
||||||
|
const issuer = input.issuer || process.env.OPENAUTH_ISSUER;
|
||||||
|
if (!issuer) throw new Error('No issuer');
|
||||||
|
const f = input.fetch ?? fetch;
|
||||||
|
|
||||||
|
async function getIssuer() {
|
||||||
|
const cached = issuerCache.get(issuer!);
|
||||||
|
if (cached) return cached;
|
||||||
|
const wellKnown = (await (f || fetch)(`${issuer}/.well-known/oauth-authorization-server`).then(
|
||||||
|
(r) => r.json()
|
||||||
|
)) as WellKnown;
|
||||||
|
issuerCache.set(issuer!, wellKnown);
|
||||||
|
return wellKnown;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getJWKS() {
|
||||||
|
const wk = await getIssuer();
|
||||||
|
const cached = jwksCache.get(issuer!);
|
||||||
|
if (cached) return cached;
|
||||||
|
const keyset = (await (f || fetch)(wk.jwks_uri).then((r) => r.json())) as JSONWebKeySet;
|
||||||
|
const result = createLocalJWKSet(keyset);
|
||||||
|
jwksCache.set(issuer!, result);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = {
|
||||||
|
async authorize(redirectURI: string, response: 'code' | 'token', opts?: AuthorizeOptions) {
|
||||||
|
const result = new URL(issuer + '/authorize');
|
||||||
|
const challenge: Challenge = {
|
||||||
|
state: crypto.randomUUID()
|
||||||
|
};
|
||||||
|
result.searchParams.set('client_id', input.clientID);
|
||||||
|
result.searchParams.set('redirect_uri', redirectURI);
|
||||||
|
result.searchParams.set('response_type', response);
|
||||||
|
result.searchParams.set('state', challenge.state);
|
||||||
|
if (opts?.provider) result.searchParams.set('provider', opts.provider);
|
||||||
|
if (opts?.pkce && response === 'code') {
|
||||||
|
const pkce = await generatePKCE();
|
||||||
|
result.searchParams.set('code_challenge_method', 'S256');
|
||||||
|
result.searchParams.set('code_challenge', pkce.challenge);
|
||||||
|
challenge.verifier = pkce.verifier;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
challenge,
|
||||||
|
url: result.toString()
|
||||||
|
};
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* @deprecated use `authorize` instead, it will do pkce by default unless disabled with `opts.pkce = false`
|
||||||
|
*/
|
||||||
|
async pkce(
|
||||||
|
redirectURI: string,
|
||||||
|
opts?: {
|
||||||
|
provider?: string;
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
const result = new URL(issuer + '/authorize');
|
||||||
|
if (opts?.provider) result.searchParams.set('provider', opts.provider);
|
||||||
|
result.searchParams.set('client_id', input.clientID);
|
||||||
|
result.searchParams.set('redirect_uri', redirectURI);
|
||||||
|
result.searchParams.set('response_type', 'code');
|
||||||
|
const pkce = await generatePKCE();
|
||||||
|
result.searchParams.set('code_challenge_method', 'S256');
|
||||||
|
result.searchParams.set('code_challenge', pkce.challenge);
|
||||||
|
return [pkce.verifier, result.toString()];
|
||||||
|
},
|
||||||
|
async exchange(
|
||||||
|
code: string,
|
||||||
|
redirectURI: string,
|
||||||
|
verifier?: string
|
||||||
|
): Promise<ExchangeSuccess | ExchangeError> {
|
||||||
|
const tokens = await f(issuer + '/token', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded'
|
||||||
|
},
|
||||||
|
body: new URLSearchParams({
|
||||||
|
code,
|
||||||
|
redirect_uri: redirectURI,
|
||||||
|
grant_type: 'authorization_code',
|
||||||
|
client_id: input.clientID,
|
||||||
|
code_verifier: verifier || ''
|
||||||
|
}).toString()
|
||||||
|
});
|
||||||
|
const json = (await tokens.json()) as any;
|
||||||
|
if (!tokens.ok) {
|
||||||
|
return {
|
||||||
|
err: new InvalidAuthorizationCodeError()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
err: false,
|
||||||
|
tokens: {
|
||||||
|
access: json.access_token as string,
|
||||||
|
refresh: json.refresh_token as string,
|
||||||
|
expiresIn: json.expires_in as number
|
||||||
|
}
|
||||||
|
};
|
||||||
|
},
|
||||||
|
async refresh(refresh: string, opts?: RefreshOptions): Promise<RefreshSuccess | RefreshError> {
|
||||||
|
if (opts && opts.access) {
|
||||||
|
const decoded = decodeJwt(opts.access);
|
||||||
|
if (!decoded) {
|
||||||
|
return {
|
||||||
|
err: new InvalidAccessTokenError()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// allow 30s window for expiration
|
||||||
|
if ((decoded.exp || 0) > Date.now() / 1000 + 30) {
|
||||||
|
return {
|
||||||
|
err: false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const tokens = await f(issuer + '/token', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded'
|
||||||
|
},
|
||||||
|
body: new URLSearchParams({
|
||||||
|
grant_type: 'refresh_token',
|
||||||
|
refresh_token: refresh
|
||||||
|
}).toString()
|
||||||
|
});
|
||||||
|
const json = (await tokens.json()) as any;
|
||||||
|
if (!tokens.ok) {
|
||||||
|
return {
|
||||||
|
err: new InvalidRefreshTokenError()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
err: false,
|
||||||
|
tokens: {
|
||||||
|
access: json.access_token as string,
|
||||||
|
refresh: json.refresh_token as string,
|
||||||
|
expiresIn: json.expires_in as number
|
||||||
|
}
|
||||||
|
};
|
||||||
|
},
|
||||||
|
async verify<T extends SubjectSchema>(
|
||||||
|
subjects: T,
|
||||||
|
token: string,
|
||||||
|
options?: VerifyOptions
|
||||||
|
): Promise<VerifyResult<T> | VerifyError> {
|
||||||
|
const jwks = await getJWKS();
|
||||||
|
try {
|
||||||
|
const result = await jwtVerify<{
|
||||||
|
mode: 'access';
|
||||||
|
type: keyof T;
|
||||||
|
properties: v1.InferInput<T[keyof T]>;
|
||||||
|
}>(token, jwks, {
|
||||||
|
issuer
|
||||||
|
});
|
||||||
|
const validated = await subjects[result.payload.type]['~standard'].validate(
|
||||||
|
result.payload.properties
|
||||||
|
);
|
||||||
|
if (!validated.issues && result.payload.mode === 'access') {
|
||||||
|
return {
|
||||||
|
aud: result.payload.aud as string,
|
||||||
|
subject: {
|
||||||
|
type: result.payload.type,
|
||||||
|
properties: validated.value
|
||||||
|
} as any
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
err: new InvalidSubjectError()
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof errors.JWTExpired && options?.refresh) {
|
||||||
|
const refreshed = await this.refresh(options.refresh);
|
||||||
|
if (refreshed.err) return refreshed;
|
||||||
|
const verified = await result.verify(subjects, refreshed.tokens!.access, {
|
||||||
|
refresh: refreshed.tokens!.refresh,
|
||||||
|
issuer,
|
||||||
|
fetch: options?.fetch
|
||||||
|
});
|
||||||
|
if (verified.err) return verified;
|
||||||
|
verified.tokens = refreshed.tokens;
|
||||||
|
return verified;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
err: new InvalidAccessTokenError()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return result;
|
||||||
|
}
|
||||||
120
packages/auth/src/error.ts
Normal file
120
packages/auth/src/error.ts
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
/**
|
||||||
|
* A list of errors that can be thrown by OpenAuth.
|
||||||
|
*
|
||||||
|
* You can use these errors to check the type of error and handle it. For example.
|
||||||
|
*
|
||||||
|
* ```ts
|
||||||
|
* import { InvalidAuthorizationCodeError } from "@openauthjs/openauth/error"
|
||||||
|
*
|
||||||
|
* if (err instanceof InvalidAuthorizationCodeError) {
|
||||||
|
* // handle invalid code error
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @packageDocumentation
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The OAuth server returned an error.
|
||||||
|
*/
|
||||||
|
export class OauthError extends Error {
|
||||||
|
constructor(
|
||||||
|
public error:
|
||||||
|
| 'invalid_request'
|
||||||
|
| 'invalid_grant'
|
||||||
|
| 'unauthorized_client'
|
||||||
|
| 'access_denied'
|
||||||
|
| 'unsupported_grant_type'
|
||||||
|
| 'server_error'
|
||||||
|
| 'temporarily_unavailable',
|
||||||
|
public description: string
|
||||||
|
) {
|
||||||
|
super(error + ' - ' + description);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The `provider` needs to be passed in.
|
||||||
|
*/
|
||||||
|
export class MissingProviderError extends OauthError {
|
||||||
|
constructor() {
|
||||||
|
super(
|
||||||
|
'invalid_request',
|
||||||
|
'Must specify `provider` query parameter if `select` callback on issuer is not specified'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The given parameter is missing.
|
||||||
|
*/
|
||||||
|
export class MissingParameterError extends OauthError {
|
||||||
|
constructor(public parameter: string) {
|
||||||
|
super('invalid_request', 'Missing parameter: ' + parameter);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The given client is not authorized to use the redirect URI that was passed in.
|
||||||
|
*/
|
||||||
|
export class UnauthorizedClientError extends OauthError {
|
||||||
|
constructor(
|
||||||
|
public clientID: string,
|
||||||
|
redirectURI: string
|
||||||
|
) {
|
||||||
|
super(
|
||||||
|
'unauthorized_client',
|
||||||
|
`Client ${clientID} is not authorized to use this redirect_uri: ${redirectURI}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The browser was in an unknown state.
|
||||||
|
*
|
||||||
|
* This can happen when certain cookies have expired. Or the browser was switched in the middle
|
||||||
|
* of the authentication flow.
|
||||||
|
*/
|
||||||
|
export class UnknownStateError extends Error {
|
||||||
|
constructor() {
|
||||||
|
super(
|
||||||
|
'The browser was in an unknown state. This could be because certain cookies expired or the browser was switched in the middle of an authentication flow.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The given subject is invalid.
|
||||||
|
*/
|
||||||
|
export class InvalidSubjectError extends Error {
|
||||||
|
constructor() {
|
||||||
|
super('Invalid subject');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The given refresh token is invalid.
|
||||||
|
*/
|
||||||
|
export class InvalidRefreshTokenError extends Error {
|
||||||
|
constructor() {
|
||||||
|
super('Invalid refresh token');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The given access token is invalid.
|
||||||
|
*/
|
||||||
|
export class InvalidAccessTokenError extends Error {
|
||||||
|
constructor() {
|
||||||
|
super('Invalid access token');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The given authorization code is invalid.
|
||||||
|
*/
|
||||||
|
export class InvalidAuthorizationCodeError extends Error {
|
||||||
|
constructor() {
|
||||||
|
super('Invalid authorization code');
|
||||||
|
}
|
||||||
|
}
|
||||||
26
packages/auth/src/index.ts
Normal file
26
packages/auth/src/index.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
export {
|
||||||
|
/**
|
||||||
|
* @deprecated
|
||||||
|
* Use `import { createClient } from "@openauthjs/openauth/client"` instead - it will tree shake better
|
||||||
|
*/
|
||||||
|
createClient
|
||||||
|
} from './client.js';
|
||||||
|
|
||||||
|
export {
|
||||||
|
/**
|
||||||
|
* @deprecated
|
||||||
|
* Use `import { createSubjects } from "@openauthjs/openauth/subject"` instead - it will tree shake better
|
||||||
|
*/
|
||||||
|
createSubjects
|
||||||
|
} from './subject.js';
|
||||||
|
|
||||||
|
import { issuer } from './issuer.js';
|
||||||
|
|
||||||
|
export {
|
||||||
|
/**
|
||||||
|
* @deprecated
|
||||||
|
* Use `import { issuer } from "@openauthjs/openauth"` instead, it was renamed
|
||||||
|
*/
|
||||||
|
issuer as authorizer,
|
||||||
|
issuer
|
||||||
|
};
|
||||||
1136
packages/auth/src/issuer.ts
Normal file
1136
packages/auth/src/issuer.ts
Normal file
File diff suppressed because it is too large
Load Diff
13
packages/auth/src/jwt.ts
Normal file
13
packages/auth/src/jwt.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { JWTPayload, jwtVerify, KeyLike, SignJWT } from 'jose';
|
||||||
|
|
||||||
|
export namespace jwt {
|
||||||
|
export function create(payload: JWTPayload, algorithm: string, privateKey: KeyLike) {
|
||||||
|
return new SignJWT(payload)
|
||||||
|
.setProtectedHeader({ alg: algorithm, typ: 'JWT', kid: 'sst' })
|
||||||
|
.sign(privateKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function verify<T>(token: string, publicKey: KeyLike) {
|
||||||
|
return jwtVerify<T>(token, publicKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
136
packages/auth/src/keys.ts
Normal file
136
packages/auth/src/keys.ts
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
import {
|
||||||
|
exportJWK,
|
||||||
|
exportPKCS8,
|
||||||
|
exportSPKI,
|
||||||
|
generateKeyPair,
|
||||||
|
importPKCS8,
|
||||||
|
importSPKI,
|
||||||
|
JWK,
|
||||||
|
KeyLike
|
||||||
|
} from 'jose';
|
||||||
|
|
||||||
|
import { Storage, StorageAdapter } from './storage/storage.js';
|
||||||
|
|
||||||
|
const signingAlg = 'ES256';
|
||||||
|
const encryptionAlg = 'RSA-OAEP-512';
|
||||||
|
|
||||||
|
interface SerializedKeyPair {
|
||||||
|
id: string;
|
||||||
|
publicKey: string;
|
||||||
|
privateKey: string;
|
||||||
|
created: number;
|
||||||
|
alg: string;
|
||||||
|
expired?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KeyPair {
|
||||||
|
id: string;
|
||||||
|
alg: string;
|
||||||
|
public: KeyLike;
|
||||||
|
private: KeyLike;
|
||||||
|
created: Date;
|
||||||
|
expired?: Date;
|
||||||
|
jwk: JWK;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated use `signingKeys` instead
|
||||||
|
*/
|
||||||
|
export async function legacySigningKeys(storage: StorageAdapter): Promise<KeyPair[]> {
|
||||||
|
const alg = 'RS512';
|
||||||
|
const results = [] as KeyPair[];
|
||||||
|
const scanner = Storage.scan<SerializedKeyPair>(storage, ['oauth:key']);
|
||||||
|
for await (const [_key, value] of scanner) {
|
||||||
|
const publicKey = await importSPKI(value.publicKey, alg, {
|
||||||
|
extractable: true
|
||||||
|
});
|
||||||
|
const privateKey = await importPKCS8(value.privateKey, alg);
|
||||||
|
const jwk = await exportJWK(publicKey);
|
||||||
|
jwk.kid = value.id;
|
||||||
|
results.push({
|
||||||
|
id: value.id,
|
||||||
|
alg,
|
||||||
|
created: new Date(value.created),
|
||||||
|
public: publicKey,
|
||||||
|
private: privateKey,
|
||||||
|
expired: new Date(1735858114000),
|
||||||
|
jwk
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function signingKeys(storage: StorageAdapter): Promise<KeyPair[]> {
|
||||||
|
const results = [] as KeyPair[];
|
||||||
|
const scanner = Storage.scan<SerializedKeyPair>(storage, ['signing:key']);
|
||||||
|
for await (const [_key, value] of scanner) {
|
||||||
|
const publicKey = await importSPKI(value.publicKey, value.alg, {
|
||||||
|
extractable: true
|
||||||
|
});
|
||||||
|
const privateKey = await importPKCS8(value.privateKey, value.alg);
|
||||||
|
const jwk = await exportJWK(publicKey);
|
||||||
|
jwk.kid = value.id;
|
||||||
|
jwk.use = 'sig';
|
||||||
|
results.push({
|
||||||
|
id: value.id,
|
||||||
|
alg: signingAlg,
|
||||||
|
created: new Date(value.created),
|
||||||
|
expired: value.expired ? new Date(value.expired) : undefined,
|
||||||
|
public: publicKey,
|
||||||
|
private: privateKey,
|
||||||
|
jwk
|
||||||
|
});
|
||||||
|
}
|
||||||
|
results.sort((a, b) => b.created.getTime() - a.created.getTime());
|
||||||
|
if (results.filter((item) => !item.expired).length) return results;
|
||||||
|
|
||||||
|
const key = await generateKeyPair(signingAlg, {
|
||||||
|
extractable: true
|
||||||
|
});
|
||||||
|
const serialized: SerializedKeyPair = {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
publicKey: await exportSPKI(key.publicKey),
|
||||||
|
privateKey: await exportPKCS8(key.privateKey),
|
||||||
|
created: Date.now(),
|
||||||
|
alg: signingAlg
|
||||||
|
};
|
||||||
|
await Storage.set(storage, ['signing:key', serialized.id], serialized);
|
||||||
|
return signingKeys(storage);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function encryptionKeys(storage: StorageAdapter): Promise<KeyPair[]> {
|
||||||
|
const results = [] as KeyPair[];
|
||||||
|
const scanner = Storage.scan<SerializedKeyPair>(storage, ['encryption:key']);
|
||||||
|
for await (const [_key, value] of scanner) {
|
||||||
|
const publicKey = await importSPKI(value.publicKey, value.alg, {
|
||||||
|
extractable: true
|
||||||
|
});
|
||||||
|
const privateKey = await importPKCS8(value.privateKey, value.alg);
|
||||||
|
const jwk = await exportJWK(publicKey);
|
||||||
|
jwk.kid = value.id;
|
||||||
|
results.push({
|
||||||
|
id: value.id,
|
||||||
|
alg: encryptionAlg,
|
||||||
|
created: new Date(value.created),
|
||||||
|
expired: value.expired ? new Date(value.expired) : undefined,
|
||||||
|
public: publicKey,
|
||||||
|
private: privateKey,
|
||||||
|
jwk
|
||||||
|
});
|
||||||
|
}
|
||||||
|
results.sort((a, b) => b.created.getTime() - a.created.getTime());
|
||||||
|
if (results.filter((item) => !item.expired).length) return results;
|
||||||
|
|
||||||
|
const key = await generateKeyPair(encryptionAlg, {
|
||||||
|
extractable: true
|
||||||
|
});
|
||||||
|
const serialized: SerializedKeyPair = {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
publicKey: await exportSPKI(key.publicKey),
|
||||||
|
privateKey: await exportPKCS8(key.privateKey),
|
||||||
|
created: Date.now(),
|
||||||
|
alg: encryptionAlg
|
||||||
|
};
|
||||||
|
await Storage.set(storage, ['encryption:key', serialized.id], serialized);
|
||||||
|
return encryptionKeys(storage);
|
||||||
|
}
|
||||||
38
packages/auth/src/pkce.ts
Normal file
38
packages/auth/src/pkce.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { base64url } from 'jose';
|
||||||
|
|
||||||
|
function generateVerifier(length: number): string {
|
||||||
|
const buffer = new Uint8Array(length);
|
||||||
|
crypto.getRandomValues(buffer);
|
||||||
|
return base64url.encode(buffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function generateChallenge(verifier: string, method: 'S256' | 'plain') {
|
||||||
|
if (method === 'plain') return verifier;
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const data = encoder.encode(verifier);
|
||||||
|
const hash = await crypto.subtle.digest('SHA-256', data);
|
||||||
|
return base64url.encode(new Uint8Array(hash));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generatePKCE(length: number = 64) {
|
||||||
|
if (length < 43 || length > 128) {
|
||||||
|
throw new Error('Code verifier length must be between 43 and 128 characters');
|
||||||
|
}
|
||||||
|
const verifier = generateVerifier(length);
|
||||||
|
const challenge = await generateChallenge(verifier, 'S256');
|
||||||
|
return {
|
||||||
|
verifier,
|
||||||
|
challenge,
|
||||||
|
method: 'S256'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function validatePKCE(
|
||||||
|
verifier: string,
|
||||||
|
challenge: string,
|
||||||
|
method: 'S256' | 'plain' = 'S256'
|
||||||
|
) {
|
||||||
|
const generatedChallenge = await generateChallenge(verifier, method);
|
||||||
|
// timing safe equals?
|
||||||
|
return generatedChallenge === challenge;
|
||||||
|
}
|
||||||
127
packages/auth/src/provider/apple.ts
Normal file
127
packages/auth/src/provider/apple.ts
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
/**
|
||||||
|
* Use this provider to authenticate with Apple. Supports both OAuth2 and OIDC.
|
||||||
|
*
|
||||||
|
* #### Using OAuth
|
||||||
|
*
|
||||||
|
* ```ts {5-8}
|
||||||
|
* import { AppleProvider } from "@openauthjs/openauth/provider/apple"
|
||||||
|
*
|
||||||
|
* export default issuer({
|
||||||
|
* providers: {
|
||||||
|
* apple: AppleProvider({
|
||||||
|
* clientID: "1234567890",
|
||||||
|
* clientSecret: "0987654321"
|
||||||
|
* })
|
||||||
|
* }
|
||||||
|
* })
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* #### Using OAuth with form_post response mode
|
||||||
|
*
|
||||||
|
* When requesting name or email scopes from Apple, you must use form_post response mode:
|
||||||
|
*
|
||||||
|
* ```ts {5-9}
|
||||||
|
* import { AppleProvider } from "@openauthjs/openauth/provider/apple"
|
||||||
|
*
|
||||||
|
* export default issuer({
|
||||||
|
* providers: {
|
||||||
|
* apple: AppleProvider({
|
||||||
|
* clientID: "1234567890",
|
||||||
|
* clientSecret: "0987654321",
|
||||||
|
* responseMode: "form_post"
|
||||||
|
* })
|
||||||
|
* }
|
||||||
|
* })
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* #### Using OIDC
|
||||||
|
*
|
||||||
|
* ```ts {5-7}
|
||||||
|
* import { AppleOidcProvider } from "@openauthjs/openauth/provider/apple"
|
||||||
|
*
|
||||||
|
* export default issuer({
|
||||||
|
* providers: {
|
||||||
|
* apple: AppleOidcProvider({
|
||||||
|
* clientID: "1234567890"
|
||||||
|
* })
|
||||||
|
* }
|
||||||
|
* })
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @packageDocumentation
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Oauth2Provider, Oauth2WrappedConfig } from './oauth2.js';
|
||||||
|
import { OidcProvider, OidcWrappedConfig } from './oidc.js';
|
||||||
|
|
||||||
|
export interface AppleConfig extends Oauth2WrappedConfig {
|
||||||
|
/**
|
||||||
|
* The response mode to use for the authorization request.
|
||||||
|
* Apple requires 'form_post' response mode when requesting name or email scopes.
|
||||||
|
* @default "query"
|
||||||
|
*/
|
||||||
|
responseMode?: 'query' | 'form_post';
|
||||||
|
}
|
||||||
|
export interface AppleOidcConfig extends OidcWrappedConfig {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create an Apple OAuth2 provider.
|
||||||
|
*
|
||||||
|
* @param config - The config for the provider.
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* // Using default query response mode (GET callback)
|
||||||
|
* AppleProvider({
|
||||||
|
* clientID: "1234567890",
|
||||||
|
* clientSecret: "0987654321"
|
||||||
|
* })
|
||||||
|
*
|
||||||
|
* // Using form_post response mode (POST callback)
|
||||||
|
* // Required when requesting name or email scope
|
||||||
|
* AppleProvider({
|
||||||
|
* clientID: "1234567890",
|
||||||
|
* clientSecret: "0987654321",
|
||||||
|
* responseMode: "form_post",
|
||||||
|
* scopes: ["name", "email"]
|
||||||
|
* })
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function AppleProvider(config: AppleConfig) {
|
||||||
|
const { responseMode, ...restConfig } = config;
|
||||||
|
const additionalQuery =
|
||||||
|
responseMode === 'form_post'
|
||||||
|
? { response_mode: 'form_post', ...config.query }
|
||||||
|
: config.query || {};
|
||||||
|
|
||||||
|
return Oauth2Provider({
|
||||||
|
...restConfig,
|
||||||
|
type: 'apple' as const,
|
||||||
|
endpoint: {
|
||||||
|
authorization: 'https://appleid.apple.com/auth/authorize',
|
||||||
|
token: 'https://appleid.apple.com/auth/token',
|
||||||
|
jwks: 'https://appleid.apple.com/auth/keys'
|
||||||
|
},
|
||||||
|
query: additionalQuery
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create an Apple OIDC provider.
|
||||||
|
*
|
||||||
|
* This is useful if you just want to verify the user's email address.
|
||||||
|
*
|
||||||
|
* @param config - The config for the provider.
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* AppleOidcProvider({
|
||||||
|
* clientID: "1234567890"
|
||||||
|
* })
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function AppleOidcProvider(config: AppleOidcConfig) {
|
||||||
|
return OidcProvider({
|
||||||
|
...config,
|
||||||
|
type: 'apple' as const,
|
||||||
|
issuer: 'https://appleid.apple.com'
|
||||||
|
});
|
||||||
|
}
|
||||||
66
packages/auth/src/provider/arctic.ts
Normal file
66
packages/auth/src/provider/arctic.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import type { OAuth2Tokens } from 'arctic';
|
||||||
|
import { Context } from 'hono';
|
||||||
|
|
||||||
|
import { OauthError } from '../error.js';
|
||||||
|
import { getRelativeUrl } from '../util.js';
|
||||||
|
import { Provider } from './provider.js';
|
||||||
|
|
||||||
|
export interface ArcticProviderOptions {
|
||||||
|
scopes: string[];
|
||||||
|
clientID: string;
|
||||||
|
clientSecret: string;
|
||||||
|
query?: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProviderState {
|
||||||
|
state: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ArcticProvider(
|
||||||
|
provider: new (
|
||||||
|
clientID: string,
|
||||||
|
clientSecret: string,
|
||||||
|
callback: string
|
||||||
|
) => {
|
||||||
|
createAuthorizationURL(state: string, scopes: string[]): URL;
|
||||||
|
validateAuthorizationCode(code: string): Promise<OAuth2Tokens>;
|
||||||
|
refreshAccessToken(refreshToken: string): Promise<OAuth2Tokens>;
|
||||||
|
},
|
||||||
|
config: ArcticProviderOptions
|
||||||
|
): Provider<{
|
||||||
|
tokenset: OAuth2Tokens;
|
||||||
|
}> {
|
||||||
|
function getClient(c: Context) {
|
||||||
|
const callback = new URL(c.req.url);
|
||||||
|
const pathname = callback.pathname.replace(/authorize.*$/, 'callback');
|
||||||
|
const url = getRelativeUrl(c, pathname);
|
||||||
|
return new provider(config.clientID, config.clientSecret, url);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
type: 'arctic',
|
||||||
|
init(routes, ctx) {
|
||||||
|
routes.get('/authorize', async (c) => {
|
||||||
|
const client = getClient(c);
|
||||||
|
const state = crypto.randomUUID();
|
||||||
|
await ctx.set(c, 'provider', 60 * 10, {
|
||||||
|
state
|
||||||
|
});
|
||||||
|
return c.redirect(client.createAuthorizationURL(state, config.scopes));
|
||||||
|
});
|
||||||
|
|
||||||
|
routes.get('/callback', async (c) => {
|
||||||
|
const client = getClient(c);
|
||||||
|
const provider = (await ctx.get(c, 'provider')) as ProviderState;
|
||||||
|
if (!provider) return c.redirect('../authorize');
|
||||||
|
const code = c.req.query('code');
|
||||||
|
const state = c.req.query('state');
|
||||||
|
if (!code) throw new Error('Missing code');
|
||||||
|
if (state !== provider.state) throw new OauthError('invalid_request', 'Invalid state');
|
||||||
|
const tokens = await client.validateAuthorizationCode(code);
|
||||||
|
return ctx.success(c, {
|
||||||
|
tokenset: tokens
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
215
packages/auth/src/provider/code.ts
Normal file
215
packages/auth/src/provider/code.ts
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
/**
|
||||||
|
* Configures a provider that supports pin code authentication. This is usually paired with the
|
||||||
|
* `CodeUI`.
|
||||||
|
*
|
||||||
|
* ```ts
|
||||||
|
* import { CodeUI } from "@openauthjs/openauth/ui/code"
|
||||||
|
* import { CodeProvider } from "@openauthjs/openauth/provider/code"
|
||||||
|
*
|
||||||
|
* export default issuer({
|
||||||
|
* providers: {
|
||||||
|
* code: CodeProvider(
|
||||||
|
* CodeUI({
|
||||||
|
* copy: {
|
||||||
|
* code_info: "We'll send a pin code to your email"
|
||||||
|
* },
|
||||||
|
* sendCode: (claims, code) => console.log(claims.email, code)
|
||||||
|
* })
|
||||||
|
* )
|
||||||
|
* },
|
||||||
|
* // ...
|
||||||
|
* })
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* You can customize the provider using.
|
||||||
|
*
|
||||||
|
* ```ts {7-9}
|
||||||
|
* const ui = CodeUI({
|
||||||
|
* // ...
|
||||||
|
* })
|
||||||
|
*
|
||||||
|
* export default issuer({
|
||||||
|
* providers: {
|
||||||
|
* code: CodeProvider(
|
||||||
|
* { ...ui, length: 4 }
|
||||||
|
* )
|
||||||
|
* },
|
||||||
|
* // ...
|
||||||
|
* })
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* Behind the scenes, the `CodeProvider` expects callbacks that implements request handlers
|
||||||
|
* that generate the UI for the following.
|
||||||
|
*
|
||||||
|
* ```ts
|
||||||
|
* CodeProvider({
|
||||||
|
* // ...
|
||||||
|
* request: (req, state, form, error) => Promise<Response>
|
||||||
|
* })
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* This allows you to create your own UI.
|
||||||
|
*
|
||||||
|
* @packageDocumentation
|
||||||
|
*/
|
||||||
|
import { Context } from 'hono';
|
||||||
|
|
||||||
|
import { generateUnbiasedDigits, timingSafeCompare } from '../random.js';
|
||||||
|
import { Provider } from './provider.js';
|
||||||
|
|
||||||
|
export interface CodeProviderConfig<
|
||||||
|
Claims extends Record<string, string> = Record<string, string>
|
||||||
|
> {
|
||||||
|
/**
|
||||||
|
* The length of the pin code.
|
||||||
|
*
|
||||||
|
* @default 6
|
||||||
|
*/
|
||||||
|
length?: number;
|
||||||
|
/**
|
||||||
|
* The request handler to generate the UI for the code flow.
|
||||||
|
*
|
||||||
|
* Takes the standard [`Request`](https://developer.mozilla.org/en-US/docs/Web/API/Request)
|
||||||
|
* and optionally [`FormData`](https://developer.mozilla.org/en-US/docs/Web/API/FormData)
|
||||||
|
* ojects.
|
||||||
|
*
|
||||||
|
* Also passes in the current `state` of the flow and any `error` that occurred.
|
||||||
|
*
|
||||||
|
* Expects the [`Response`](https://developer.mozilla.org/en-US/docs/Web/API/Response) object
|
||||||
|
* in return.
|
||||||
|
*/
|
||||||
|
request: (
|
||||||
|
req: Request,
|
||||||
|
state: CodeProviderState,
|
||||||
|
form?: FormData,
|
||||||
|
error?: CodeProviderError
|
||||||
|
) => Promise<Response>;
|
||||||
|
/**
|
||||||
|
* Callback to send the pin code to the user.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* {
|
||||||
|
* sendCode: async (claims, code) => {
|
||||||
|
* // Send the code through the email or phone number based on the claims
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
sendCode: (claims: Claims, code: string) => Promise<void | CodeProviderError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The state of the code flow.
|
||||||
|
*
|
||||||
|
* | State | Description |
|
||||||
|
* | ----- | ----------- |
|
||||||
|
* | `start` | The user is asked to enter their email address or phone number to start the flow. |
|
||||||
|
* | `code` | The user needs to enter the pin code to verify their _claim_. |
|
||||||
|
*/
|
||||||
|
export type CodeProviderState =
|
||||||
|
| {
|
||||||
|
type: 'start';
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'code';
|
||||||
|
resend?: boolean;
|
||||||
|
code: string;
|
||||||
|
claims: Record<string, string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The errors that can happen on the code flow.
|
||||||
|
*
|
||||||
|
* | Error | Description |
|
||||||
|
* | ----- | ----------- |
|
||||||
|
* | `invalid_code` | The code is invalid. |
|
||||||
|
* | `invalid_claim` | The _claim_, email or phone number, is invalid. |
|
||||||
|
*/
|
||||||
|
export type CodeProviderError =
|
||||||
|
| {
|
||||||
|
type: 'invalid_code';
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'invalid_claim';
|
||||||
|
key: string;
|
||||||
|
value: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function CodeProvider<Claims extends Record<string, string> = Record<string, string>>(
|
||||||
|
config: CodeProviderConfig<Claims>
|
||||||
|
): Provider<{ claims: Claims }> {
|
||||||
|
const length = config.length || 6;
|
||||||
|
function generate() {
|
||||||
|
return generateUnbiasedDigits(length);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'code',
|
||||||
|
init(routes, ctx) {
|
||||||
|
async function transition(
|
||||||
|
c: Context,
|
||||||
|
next: CodeProviderState,
|
||||||
|
fd?: FormData,
|
||||||
|
err?: CodeProviderError
|
||||||
|
) {
|
||||||
|
await ctx.set<CodeProviderState>(c, 'provider', 60 * 60 * 24, next);
|
||||||
|
const resp = ctx.forward(c, await config.request(c.req.raw, next, fd, err));
|
||||||
|
return resp;
|
||||||
|
}
|
||||||
|
routes.get('/authorize', async (c) => {
|
||||||
|
const resp = await transition(c, {
|
||||||
|
type: 'start'
|
||||||
|
});
|
||||||
|
return resp;
|
||||||
|
});
|
||||||
|
|
||||||
|
routes.post('/authorize', async (c) => {
|
||||||
|
const code = generate();
|
||||||
|
const fd = await c.req.formData();
|
||||||
|
const state = await ctx.get<CodeProviderState>(c, 'provider');
|
||||||
|
const action = fd.get('action')?.toString();
|
||||||
|
|
||||||
|
if (action === 'request' || action === 'resend') {
|
||||||
|
const claims = Object.fromEntries(fd) as Claims;
|
||||||
|
delete claims.action;
|
||||||
|
const err = await config.sendCode(claims, code);
|
||||||
|
if (err) return transition(c, { type: 'start' }, fd, err);
|
||||||
|
return transition(
|
||||||
|
c,
|
||||||
|
{
|
||||||
|
type: 'code',
|
||||||
|
resend: action === 'resend',
|
||||||
|
claims,
|
||||||
|
code
|
||||||
|
},
|
||||||
|
fd
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fd.get('action')?.toString() === 'verify' && state.type === 'code') {
|
||||||
|
const fd = await c.req.formData();
|
||||||
|
const compare = fd.get('code')?.toString();
|
||||||
|
if (!state.code || !compare || !timingSafeCompare(state.code, compare)) {
|
||||||
|
return transition(
|
||||||
|
c,
|
||||||
|
{
|
||||||
|
...state,
|
||||||
|
resend: false
|
||||||
|
},
|
||||||
|
fd,
|
||||||
|
{ type: 'invalid_code' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await ctx.unset(c, 'provider');
|
||||||
|
return ctx.forward(c, await ctx.success(c, { claims: state.claims as Claims }));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
export type CodeProviderOptions = Parameters<typeof CodeProvider>[0];
|
||||||
74
packages/auth/src/provider/cognito.ts
Normal file
74
packages/auth/src/provider/cognito.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
/**
|
||||||
|
* Use this provider to authenticate with a Cognito OAuth endpoint.
|
||||||
|
*
|
||||||
|
* ```ts {5-10}
|
||||||
|
* import { CognitoProvider } from "@openauthjs/openauth/provider/cognito"
|
||||||
|
*
|
||||||
|
* export default issuer({
|
||||||
|
* providers: {
|
||||||
|
* cognito: CognitoProvider({
|
||||||
|
* domain: "your-domain.auth.us-east-1.amazoncognito.com",
|
||||||
|
* region: "us-east-1",
|
||||||
|
* clientID: "1234567890",
|
||||||
|
* clientSecret: "0987654321"
|
||||||
|
* })
|
||||||
|
* }
|
||||||
|
* })
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @packageDocumentation
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Oauth2Provider, Oauth2WrappedConfig } from './oauth2.js';
|
||||||
|
|
||||||
|
export interface CognitoConfig extends Oauth2WrappedConfig {
|
||||||
|
/**
|
||||||
|
* The domain of the Cognito User Pool.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* {
|
||||||
|
* domain: "your-domain.auth.us-east-1.amazoncognito.com"
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
domain: string;
|
||||||
|
/**
|
||||||
|
* The region the Cognito User Pool is in.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* {
|
||||||
|
* region: "us-east-1"
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
region: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a Cognito OAuth2 provider.
|
||||||
|
*
|
||||||
|
* @param config - The config for the provider.
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* CognitoProvider({
|
||||||
|
* domain: "your-domain.auth.us-east-1.amazoncognito.com",
|
||||||
|
* region: "us-east-1",
|
||||||
|
* clientID: "1234567890",
|
||||||
|
* clientSecret: "0987654321"
|
||||||
|
* })
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function CognitoProvider(config: CognitoConfig) {
|
||||||
|
const domain = `${config.domain}.auth.${config.region}.amazoncognito.com`;
|
||||||
|
|
||||||
|
return Oauth2Provider({
|
||||||
|
type: 'cognito',
|
||||||
|
...config,
|
||||||
|
endpoint: {
|
||||||
|
authorization: `https://${domain}/oauth2/authorize`,
|
||||||
|
token: `https://${domain}/oauth2/token`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
45
packages/auth/src/provider/discord.ts
Normal file
45
packages/auth/src/provider/discord.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
/**
|
||||||
|
* Use this provider to authenticate with Discord.
|
||||||
|
*
|
||||||
|
* ```ts {5-8}
|
||||||
|
* import { DiscordProvider } from "@openauthjs/openauth/provider/discord"
|
||||||
|
*
|
||||||
|
* export default issuer({
|
||||||
|
* providers: {
|
||||||
|
* discord: DiscordProvider({
|
||||||
|
* clientID: "1234567890",
|
||||||
|
* clientSecret: "0987654321"
|
||||||
|
* })
|
||||||
|
* }
|
||||||
|
* })
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @packageDocumentation
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Oauth2Provider, Oauth2WrappedConfig } from './oauth2.js';
|
||||||
|
|
||||||
|
export interface DiscordConfig extends Oauth2WrappedConfig {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a Discord OAuth2 provider.
|
||||||
|
*
|
||||||
|
* @param config - The config for the provider.
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* DiscordProvider({
|
||||||
|
* clientID: "1234567890",
|
||||||
|
* clientSecret: "0987654321"
|
||||||
|
* })
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function DiscordProvider(config: DiscordConfig) {
|
||||||
|
return Oauth2Provider({
|
||||||
|
type: 'discord',
|
||||||
|
...config,
|
||||||
|
endpoint: {
|
||||||
|
authorization: 'https://discord.com/oauth2/authorize',
|
||||||
|
token: 'https://discord.com/api/oauth2/token'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
84
packages/auth/src/provider/facebook.ts
Normal file
84
packages/auth/src/provider/facebook.ts
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
/**
|
||||||
|
* Use this provider to authenticate with Facebook. Supports both OAuth2 and OIDC.
|
||||||
|
*
|
||||||
|
* #### Using OAuth
|
||||||
|
*
|
||||||
|
* ```ts {5-8}
|
||||||
|
* import { FacebookProvider } from "@openauthjs/openauth/provider/facebook"
|
||||||
|
*
|
||||||
|
* export default issuer({
|
||||||
|
* providers: {
|
||||||
|
* facebook: FacebookProvider({
|
||||||
|
* clientID: "1234567890",
|
||||||
|
* clientSecret: "0987654321"
|
||||||
|
* })
|
||||||
|
* }
|
||||||
|
* })
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* #### Using OIDC
|
||||||
|
*
|
||||||
|
* ```ts {5-7}
|
||||||
|
* import { FacebookOidcProvider } from "@openauthjs/openauth/provider/facebook"
|
||||||
|
*
|
||||||
|
* export default issuer({
|
||||||
|
* providers: {
|
||||||
|
* facebook: FacebookOidcProvider({
|
||||||
|
* clientID: "1234567890"
|
||||||
|
* })
|
||||||
|
* }
|
||||||
|
* })
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @packageDocumentation
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Oauth2Provider, Oauth2WrappedConfig } from './oauth2.js';
|
||||||
|
import { OidcProvider, OidcWrappedConfig } from './oidc.js';
|
||||||
|
|
||||||
|
export interface FacebookConfig extends Oauth2WrappedConfig {}
|
||||||
|
export interface FacebookOidcConfig extends OidcWrappedConfig {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a Facebook OAuth2 provider.
|
||||||
|
*
|
||||||
|
* @param config - The config for the provider.
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* FacebookProvider({
|
||||||
|
* clientID: "1234567890",
|
||||||
|
* clientSecret: "0987654321"
|
||||||
|
* })
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function FacebookProvider(config: FacebookConfig) {
|
||||||
|
return Oauth2Provider({
|
||||||
|
...config,
|
||||||
|
type: 'facebook',
|
||||||
|
endpoint: {
|
||||||
|
authorization: 'https://www.facebook.com/v12.0/dialog/oauth',
|
||||||
|
token: 'https://graph.facebook.com/v12.0/oauth/access_token'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a Facebook OIDC provider.
|
||||||
|
*
|
||||||
|
* This is useful if you just want to verify the user's email address.
|
||||||
|
*
|
||||||
|
* @param config - The config for the provider.
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* FacebookOidcProvider({
|
||||||
|
* clientID: "1234567890"
|
||||||
|
* })
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function FacebookOidcProvider(config: FacebookOidcConfig) {
|
||||||
|
return OidcProvider({
|
||||||
|
...config,
|
||||||
|
type: 'facebook',
|
||||||
|
issuer: 'https://graph.facebook.com'
|
||||||
|
});
|
||||||
|
}
|
||||||
45
packages/auth/src/provider/github.ts
Normal file
45
packages/auth/src/provider/github.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
/**
|
||||||
|
* Use this provider to authenticate with Github.
|
||||||
|
*
|
||||||
|
* ```ts {5-8}
|
||||||
|
* import { GithubProvider } from "@openauthjs/openauth/provider/github"
|
||||||
|
*
|
||||||
|
* export default issuer({
|
||||||
|
* providers: {
|
||||||
|
* github: GithubProvider({
|
||||||
|
* clientID: "1234567890",
|
||||||
|
* clientSecret: "0987654321"
|
||||||
|
* })
|
||||||
|
* }
|
||||||
|
* })
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @packageDocumentation
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Oauth2Provider, Oauth2WrappedConfig } from './oauth2.js';
|
||||||
|
|
||||||
|
export interface GithubConfig extends Oauth2WrappedConfig {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a Github OAuth2 provider.
|
||||||
|
*
|
||||||
|
* @param config - The config for the provider.
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* GithubProvider({
|
||||||
|
* clientID: "1234567890",
|
||||||
|
* clientSecret: "0987654321"
|
||||||
|
* })
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function GithubProvider(config: GithubConfig) {
|
||||||
|
return Oauth2Provider({
|
||||||
|
...config,
|
||||||
|
type: 'github',
|
||||||
|
endpoint: {
|
||||||
|
authorization: 'https://github.com/login/oauth/authorize',
|
||||||
|
token: 'https://github.com/login/oauth/access_token'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
85
packages/auth/src/provider/google.ts
Normal file
85
packages/auth/src/provider/google.ts
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
/**
|
||||||
|
* Use this provider to authenticate with Google. Supports both OAuth2 and OIDC.
|
||||||
|
*
|
||||||
|
* #### Using OAuth
|
||||||
|
*
|
||||||
|
* ```ts {5-8}
|
||||||
|
* import { GoogleProvider } from "@openauthjs/openauth/provider/google"
|
||||||
|
*
|
||||||
|
* export default issuer({
|
||||||
|
* providers: {
|
||||||
|
* google: GoogleProvider({
|
||||||
|
* clientID: "1234567890",
|
||||||
|
* clientSecret: "0987654321"
|
||||||
|
* })
|
||||||
|
* }
|
||||||
|
* })
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* #### Using OIDC
|
||||||
|
*
|
||||||
|
* ```ts {5-7}
|
||||||
|
* import { GoogleOidcProvider } from "@openauthjs/openauth/provider/google"
|
||||||
|
*
|
||||||
|
* export default issuer({
|
||||||
|
* providers: {
|
||||||
|
* google: GoogleOidcProvider({
|
||||||
|
* clientID: "1234567890"
|
||||||
|
* })
|
||||||
|
* }
|
||||||
|
* })
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @packageDocumentation
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Oauth2Provider, Oauth2WrappedConfig } from './oauth2.js';
|
||||||
|
import { OidcProvider, OidcWrappedConfig } from './oidc.js';
|
||||||
|
|
||||||
|
export interface GoogleConfig extends Oauth2WrappedConfig {}
|
||||||
|
export interface GoogleOidcConfig extends OidcWrappedConfig {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a Google OAuth2 provider.
|
||||||
|
*
|
||||||
|
* @param config - The config for the provider.
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* GoogleProvider({
|
||||||
|
* clientID: "1234567890",
|
||||||
|
* clientSecret: "0987654321"
|
||||||
|
* })
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function GoogleProvider(config: GoogleConfig) {
|
||||||
|
return Oauth2Provider({
|
||||||
|
...config,
|
||||||
|
type: 'google',
|
||||||
|
endpoint: {
|
||||||
|
authorization: 'https://accounts.google.com/o/oauth2/v2/auth',
|
||||||
|
token: 'https://oauth2.googleapis.com/token',
|
||||||
|
jwks: 'https://www.googleapis.com/oauth2/v3/certs'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a Google OIDC provider.
|
||||||
|
*
|
||||||
|
* This is useful if you just want to verify the user's email address.
|
||||||
|
*
|
||||||
|
* @param config - The config for the provider.
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* GoogleOidcProvider({
|
||||||
|
* clientID: "1234567890"
|
||||||
|
* })
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function GoogleOidcProvider(config: GoogleOidcConfig) {
|
||||||
|
return OidcProvider({
|
||||||
|
...config,
|
||||||
|
type: 'google',
|
||||||
|
issuer: 'https://accounts.google.com'
|
||||||
|
});
|
||||||
|
}
|
||||||
5
packages/auth/src/provider/index.ts
Normal file
5
packages/auth/src/provider/index.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export * from './code.js';
|
||||||
|
export type { Provider } from './provider.js';
|
||||||
|
export * from './spotify.js';
|
||||||
|
export * from './ssh.js';
|
||||||
|
export * from './steam.js';
|
||||||
45
packages/auth/src/provider/jumpcloud.ts
Normal file
45
packages/auth/src/provider/jumpcloud.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
/**
|
||||||
|
* Use this provider to authenticate with JumpCloud.
|
||||||
|
*
|
||||||
|
* ```ts {5-8}
|
||||||
|
* import { JumpCloudProvider } from "@openauthjs/openauth/provider/jumpcloud"
|
||||||
|
*
|
||||||
|
* export default issuer({
|
||||||
|
* providers: {
|
||||||
|
* jumpcloud: JumpCloudProvider({
|
||||||
|
* clientID: "1234567890",
|
||||||
|
* clientSecret: "0987654321"
|
||||||
|
* })
|
||||||
|
* }
|
||||||
|
* })
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @packageDocumentation
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Oauth2Provider, Oauth2WrappedConfig } from './oauth2.js';
|
||||||
|
|
||||||
|
export interface JumpCloudConfig extends Oauth2WrappedConfig {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a JumpCloud OAuth2 provider.
|
||||||
|
*
|
||||||
|
* @param config - The config for the provider.
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* JumpCloudProvider({
|
||||||
|
* clientID: "1234567890",
|
||||||
|
* clientSecret: "0987654321"
|
||||||
|
* })
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function JumpCloudProvider(config: JumpCloudConfig) {
|
||||||
|
return Oauth2Provider({
|
||||||
|
type: 'jumpcloud',
|
||||||
|
...config,
|
||||||
|
endpoint: {
|
||||||
|
authorization: 'https://oauth.id.jumpcloud.com/oauth2/auth',
|
||||||
|
token: 'https://oauth.id.jumpcloud.com/oauth2/token'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
75
packages/auth/src/provider/keycloak.ts
Normal file
75
packages/auth/src/provider/keycloak.ts
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
/**
|
||||||
|
* Use this provider to authenticate with a Keycloak server.
|
||||||
|
*
|
||||||
|
* ```ts {5-10}
|
||||||
|
* import { KeycloakProvider } from "@openauthjs/openauth/provider/keycloak"
|
||||||
|
*
|
||||||
|
* export default issuer({
|
||||||
|
* providers: {
|
||||||
|
* keycloak: KeycloakProvider({
|
||||||
|
* baseUrl: "https://your-keycloak-domain",
|
||||||
|
* realm: "your-realm",
|
||||||
|
* clientID: "1234567890",
|
||||||
|
* clientSecret: "0987654321"
|
||||||
|
* })
|
||||||
|
* }
|
||||||
|
* })
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @packageDocumentation
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Oauth2Provider, Oauth2WrappedConfig } from './oauth2.js';
|
||||||
|
|
||||||
|
export interface KeycloakConfig extends Oauth2WrappedConfig {
|
||||||
|
/**
|
||||||
|
* The base URL of the Keycloak server.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* {
|
||||||
|
* baseUrl: "https://your-keycloak-domain"
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
baseUrl: string;
|
||||||
|
/**
|
||||||
|
* The realm in the Keycloak server to authenticate against.
|
||||||
|
*
|
||||||
|
* A realm in Keycloak is like a tenant or namespace that manages a set of
|
||||||
|
* users, credentials, roles, and groups.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* {
|
||||||
|
* realm: "your-realm"
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
realm: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a Keycloak OAuth2 provider.
|
||||||
|
*
|
||||||
|
* @param config - The config for the provider.
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* KeycloakProvider({
|
||||||
|
* baseUrl: "https://your-keycloak-domain",
|
||||||
|
* realm: "your-realm",
|
||||||
|
* clientID: "1234567890",
|
||||||
|
* clientSecret: "0987654321"
|
||||||
|
* })
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function KeycloakProvider(config: KeycloakConfig) {
|
||||||
|
const baseConfig = {
|
||||||
|
...config,
|
||||||
|
endpoint: {
|
||||||
|
authorization: `${config.baseUrl}/realms/${config.realm}/protocol/openid-connect/auth`,
|
||||||
|
token: `${config.baseUrl}/realms/${config.realm}/protocol/openid-connect/token`
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return Oauth2Provider(baseConfig);
|
||||||
|
}
|
||||||
12
packages/auth/src/provider/linkedin.ts
Normal file
12
packages/auth/src/provider/linkedin.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { Oauth2Provider, type Oauth2WrappedConfig } from './oauth2.js';
|
||||||
|
|
||||||
|
export function LinkedInAdapter(config: Oauth2WrappedConfig) {
|
||||||
|
return Oauth2Provider({
|
||||||
|
...config,
|
||||||
|
type: 'linkedin',
|
||||||
|
endpoint: {
|
||||||
|
authorization: 'https://www.linkedin.com/oauth/v2/authorization',
|
||||||
|
token: 'https://www.linkedin.com/oauth/v2/accessToken'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
100
packages/auth/src/provider/microsoft.ts
Normal file
100
packages/auth/src/provider/microsoft.ts
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
/**
|
||||||
|
* Use this provider to authenticate with Microsoft. Supports both OAuth2 and OIDC.
|
||||||
|
*
|
||||||
|
* #### Using OAuth
|
||||||
|
*
|
||||||
|
* ```ts {5-9}
|
||||||
|
* import { MicrosoftProvider } from "@openauthjs/openauth/provider/microsoft"
|
||||||
|
*
|
||||||
|
* export default issuer({
|
||||||
|
* providers: {
|
||||||
|
* microsoft: MicrosoftProvider({
|
||||||
|
* tenant: "1234567890",
|
||||||
|
* clientID: "1234567890",
|
||||||
|
* clientSecret: "0987654321"
|
||||||
|
* })
|
||||||
|
* }
|
||||||
|
* })
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* #### Using OIDC
|
||||||
|
*
|
||||||
|
* ```ts {5-7}
|
||||||
|
* import { MicrosoftOidcProvider } from "@openauthjs/openauth/provider/microsoft"
|
||||||
|
*
|
||||||
|
* export default issuer({
|
||||||
|
* providers: {
|
||||||
|
* microsoft: MicrosoftOidcProvider({
|
||||||
|
* clientID: "1234567890"
|
||||||
|
* })
|
||||||
|
* }
|
||||||
|
* })
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @packageDocumentation
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Oauth2Provider, Oauth2WrappedConfig } from './oauth2.js';
|
||||||
|
import { OidcProvider, OidcWrappedConfig } from './oidc.js';
|
||||||
|
|
||||||
|
export interface MicrosoftConfig extends Oauth2WrappedConfig {
|
||||||
|
/**
|
||||||
|
* The tenant ID of the Microsoft account.
|
||||||
|
*
|
||||||
|
* This is usually the same as the client ID.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* {
|
||||||
|
* tenant: "1234567890"
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
tenant: string;
|
||||||
|
}
|
||||||
|
export interface MicrosoftOidcConfig extends OidcWrappedConfig {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a Microsoft OAuth2 provider.
|
||||||
|
*
|
||||||
|
* @param config - The config for the provider.
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* MicrosoftProvider({
|
||||||
|
* tenant: "1234567890",
|
||||||
|
* clientID: "1234567890",
|
||||||
|
* clientSecret: "0987654321"
|
||||||
|
* })
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function MicrosoftProvider(config: MicrosoftConfig) {
|
||||||
|
return Oauth2Provider({
|
||||||
|
...config,
|
||||||
|
type: 'microsoft',
|
||||||
|
endpoint: {
|
||||||
|
authorization: `https://login.microsoftonline.com/${config?.tenant}/oauth2/v2.0/authorize`,
|
||||||
|
token: `https://login.microsoftonline.com/${config?.tenant}/oauth2/v2.0/token`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a Microsoft OIDC provider.
|
||||||
|
*
|
||||||
|
* This is useful if you just want to verify the user's email address.
|
||||||
|
*
|
||||||
|
* @param config - The config for the provider.
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* MicrosoftOidcProvider({
|
||||||
|
* clientID: "1234567890"
|
||||||
|
* })
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function MicrosoftOidcProvider(config: MicrosoftOidcConfig) {
|
||||||
|
return OidcProvider({
|
||||||
|
...config,
|
||||||
|
type: 'microsoft',
|
||||||
|
issuer: 'https://graph.microsoft.com/oidc/userinfo'
|
||||||
|
});
|
||||||
|
}
|
||||||
282
packages/auth/src/provider/oauth2.ts
Normal file
282
packages/auth/src/provider/oauth2.ts
Normal file
@@ -0,0 +1,282 @@
|
|||||||
|
/**
|
||||||
|
* Use this to connect authentication providers that support OAuth 2.0.
|
||||||
|
*
|
||||||
|
* ```ts {5-12}
|
||||||
|
* import { Oauth2Provider } from "@openauthjs/openauth/provider/oauth2"
|
||||||
|
*
|
||||||
|
* export default issuer({
|
||||||
|
* providers: {
|
||||||
|
* oauth2: Oauth2Provider({
|
||||||
|
* clientID: "1234567890",
|
||||||
|
* clientSecret: "0987654321",
|
||||||
|
* endpoint: {
|
||||||
|
* authorization: "https://auth.myserver.com/authorize",
|
||||||
|
* token: "https://auth.myserver.com/token"
|
||||||
|
* }
|
||||||
|
* })
|
||||||
|
* }
|
||||||
|
* })
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
*
|
||||||
|
* @packageDocumentation
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createRemoteJWKSet, jwtVerify } from 'jose';
|
||||||
|
|
||||||
|
import { OauthError } from '../error.js';
|
||||||
|
import { generatePKCE } from '../pkce.js';
|
||||||
|
import { getRelativeUrl } from '../util.js';
|
||||||
|
import { Provider } from './provider.js';
|
||||||
|
|
||||||
|
export interface Oauth2Config {
|
||||||
|
/**
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
type?: string;
|
||||||
|
/**
|
||||||
|
* The client ID.
|
||||||
|
*
|
||||||
|
* This is just a string to identify your app.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* {
|
||||||
|
* clientID: "my-client"
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
clientID: string;
|
||||||
|
/**
|
||||||
|
* The client secret.
|
||||||
|
*
|
||||||
|
* This is a private key that's used to authenticate your app. It should be kept secret.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* {
|
||||||
|
* clientSecret: "0987654321"
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
clientSecret: string;
|
||||||
|
/**
|
||||||
|
* The URLs of the authorization and token endpoints.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* {
|
||||||
|
* endpoint: {
|
||||||
|
* authorization: "https://auth.myserver.com/authorize",
|
||||||
|
* token: "https://auth.myserver.com/token",
|
||||||
|
* jwks: "https://auth.myserver.com/auth/keys"
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
endpoint: {
|
||||||
|
/**
|
||||||
|
* The URL of the authorization endpoint.
|
||||||
|
*/
|
||||||
|
authorization: string;
|
||||||
|
/**
|
||||||
|
* The URL of the token endpoint.
|
||||||
|
*/
|
||||||
|
token: string;
|
||||||
|
/**
|
||||||
|
* The URL of the JWKS endpoint.
|
||||||
|
*/
|
||||||
|
jwks?: string;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* A list of OAuth scopes that you want to request.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* {
|
||||||
|
* scopes: ["email", "profile"]
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
scopes: string[];
|
||||||
|
/**
|
||||||
|
* Whether to use PKCE (Proof Key for Code Exchange) for the authorization code flow.
|
||||||
|
* Some providers like x.com require this.
|
||||||
|
* @default false
|
||||||
|
*/
|
||||||
|
pkce?: boolean;
|
||||||
|
/**
|
||||||
|
* Any additional parameters that you want to pass to the authorization endpoint.
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* {
|
||||||
|
* query: {
|
||||||
|
* access_type: "offline",
|
||||||
|
* prompt: "consent"
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
query?: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
export type Oauth2WrappedConfig = Omit<Oauth2Config, 'endpoint' | 'name'>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
export interface Oauth2Token {
|
||||||
|
access: string;
|
||||||
|
refresh: string;
|
||||||
|
expiry: number;
|
||||||
|
id?: Record<string, any>;
|
||||||
|
raw: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProviderState {
|
||||||
|
state: string;
|
||||||
|
redirect: string;
|
||||||
|
codeVerifier?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Oauth2Provider(
|
||||||
|
config: Oauth2Config
|
||||||
|
): Provider<{ tokenset: Oauth2Token; clientID: string }> {
|
||||||
|
const query = config.query || {};
|
||||||
|
|
||||||
|
// Helper function to handle token exchange and response building
|
||||||
|
async function handleCallbackLogic(
|
||||||
|
c: any,
|
||||||
|
ctx: any,
|
||||||
|
provider: ProviderState,
|
||||||
|
code: string | undefined
|
||||||
|
) {
|
||||||
|
if (!provider || !code) {
|
||||||
|
return c.redirect(getRelativeUrl(c, './authorize'));
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = new URLSearchParams({
|
||||||
|
client_id: config.clientID,
|
||||||
|
client_secret: config.clientSecret,
|
||||||
|
code,
|
||||||
|
grant_type: 'authorization_code',
|
||||||
|
redirect_uri: provider.redirect,
|
||||||
|
...(provider.codeVerifier ? { code_verifier: provider.codeVerifier } : {})
|
||||||
|
});
|
||||||
|
|
||||||
|
const json: any = await fetch(config.endpoint.token, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
Accept: 'application/json'
|
||||||
|
},
|
||||||
|
body: body.toString()
|
||||||
|
}).then((r) => r.json());
|
||||||
|
|
||||||
|
if ('error' in json) {
|
||||||
|
throw new OauthError(json.error, json.error_description);
|
||||||
|
}
|
||||||
|
|
||||||
|
let idTokenPayload: Record<string, any> | null = null;
|
||||||
|
if (config.endpoint.jwks) {
|
||||||
|
const jwksEndpoint = new URL(config.endpoint.jwks);
|
||||||
|
// @ts-expect-error bun/node mismatch
|
||||||
|
const jwks = createRemoteJWKSet(jwksEndpoint);
|
||||||
|
const { payload } = await jwtVerify(json.id_token, jwks, {
|
||||||
|
audience: config.clientID
|
||||||
|
});
|
||||||
|
idTokenPayload = payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ctx.success(c, {
|
||||||
|
clientID: config.clientID,
|
||||||
|
tokenset: {
|
||||||
|
get access() {
|
||||||
|
return json.access_token;
|
||||||
|
},
|
||||||
|
get refresh() {
|
||||||
|
return json.refresh_token;
|
||||||
|
},
|
||||||
|
get expiry() {
|
||||||
|
return json.expires_in;
|
||||||
|
},
|
||||||
|
get id() {
|
||||||
|
if (!idTokenPayload) return null;
|
||||||
|
return idTokenPayload;
|
||||||
|
},
|
||||||
|
get raw() {
|
||||||
|
return json;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: config.type || 'oauth2',
|
||||||
|
init(routes, ctx) {
|
||||||
|
routes.get('/authorize', async (c) => {
|
||||||
|
const state = crypto.randomUUID();
|
||||||
|
const pkce = config.pkce ? await generatePKCE() : undefined;
|
||||||
|
await ctx.set<ProviderState>(c, 'provider', 60 * 10, {
|
||||||
|
state,
|
||||||
|
redirect: getRelativeUrl(c, './callback'),
|
||||||
|
codeVerifier: pkce?.verifier
|
||||||
|
});
|
||||||
|
const authorization = new URL(config.endpoint.authorization);
|
||||||
|
authorization.searchParams.set('client_id', config.clientID);
|
||||||
|
authorization.searchParams.set('redirect_uri', getRelativeUrl(c, './callback'));
|
||||||
|
authorization.searchParams.set('response_type', 'code');
|
||||||
|
authorization.searchParams.set('state', state);
|
||||||
|
authorization.searchParams.set('scope', config.scopes.join(' '));
|
||||||
|
if (pkce) {
|
||||||
|
authorization.searchParams.set('code_challenge', pkce.challenge);
|
||||||
|
authorization.searchParams.set('code_challenge_method', pkce.method);
|
||||||
|
}
|
||||||
|
for (const [key, value] of Object.entries(query)) {
|
||||||
|
authorization.searchParams.set(key, value);
|
||||||
|
}
|
||||||
|
return c.redirect(authorization.toString());
|
||||||
|
});
|
||||||
|
|
||||||
|
routes.get('/callback', async (c) => {
|
||||||
|
const provider = (await ctx.get(c, 'provider')) as ProviderState;
|
||||||
|
const code = c.req.query('code');
|
||||||
|
const state = c.req.query('state');
|
||||||
|
const error = c.req.query('error');
|
||||||
|
|
||||||
|
if (error)
|
||||||
|
throw new OauthError(
|
||||||
|
error.toString() as any,
|
||||||
|
c.req.query('error_description')?.toString() || ''
|
||||||
|
);
|
||||||
|
if (!provider || !code || (provider.state && state !== provider.state)) {
|
||||||
|
return c.redirect(getRelativeUrl(c, './authorize'));
|
||||||
|
}
|
||||||
|
|
||||||
|
return handleCallbackLogic(c, ctx, provider, code);
|
||||||
|
});
|
||||||
|
|
||||||
|
routes.post('/callback', async (c) => {
|
||||||
|
const provider = (await ctx.get(c, 'provider')) as ProviderState;
|
||||||
|
|
||||||
|
// Handle form data from POST request
|
||||||
|
const formData = await c.req.formData();
|
||||||
|
const code = formData.get('code')?.toString();
|
||||||
|
const state = formData.get('state')?.toString();
|
||||||
|
const error = formData.get('error')?.toString();
|
||||||
|
|
||||||
|
if (error)
|
||||||
|
throw new OauthError(error as any, formData.get('error_description')?.toString() || '');
|
||||||
|
|
||||||
|
if (!provider || !code || (provider.state && state !== provider.state)) {
|
||||||
|
return c.redirect(getRelativeUrl(c, './authorize'));
|
||||||
|
}
|
||||||
|
|
||||||
|
return handleCallbackLogic(c, ctx, provider, code);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
173
packages/auth/src/provider/oidc.ts
Normal file
173
packages/auth/src/provider/oidc.ts
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
/**
|
||||||
|
* Use this to connect authentication providers that support OIDC.
|
||||||
|
*
|
||||||
|
* ```ts {5-8}
|
||||||
|
* import { OidcProvider } from "@openauthjs/openauth/provider/oidc"
|
||||||
|
*
|
||||||
|
* export default issuer({
|
||||||
|
* providers: {
|
||||||
|
* oauth2: OidcProvider({
|
||||||
|
* clientId: "1234567890",
|
||||||
|
* issuer: "https://auth.myserver.com"
|
||||||
|
* })
|
||||||
|
* }
|
||||||
|
* })
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
*
|
||||||
|
* @packageDocumentation
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { JWTPayload } from 'hono/utils/jwt/types';
|
||||||
|
import { createLocalJWKSet, JSONWebKeySet, jwtVerify } from 'jose';
|
||||||
|
|
||||||
|
import { WellKnown } from '../client.js';
|
||||||
|
import { OauthError } from '../error.js';
|
||||||
|
import { getRelativeUrl, lazy } from '../util.js';
|
||||||
|
import { Provider } from './provider.js';
|
||||||
|
|
||||||
|
export interface OidcConfig {
|
||||||
|
/**
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
type?: string;
|
||||||
|
/**
|
||||||
|
* The client ID.
|
||||||
|
*
|
||||||
|
* This is just a string to identify your app.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* {
|
||||||
|
* clientID: "my-client"
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
clientID: string;
|
||||||
|
/**
|
||||||
|
* The URL of your authorization server.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* {
|
||||||
|
* issuer: "https://auth.myserver.com"
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
issuer: string;
|
||||||
|
/**
|
||||||
|
* A list of OIDC scopes that you want to request.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* {
|
||||||
|
* scopes: ["openid", "profile", "email"]
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
scopes?: string[];
|
||||||
|
/**
|
||||||
|
* Any additional parameters that you want to pass to the authorization endpoint.
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* {
|
||||||
|
* query: {
|
||||||
|
* prompt: "consent"
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
query?: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
export type OidcWrappedConfig = Omit<OidcConfig, 'issuer' | 'name'>;
|
||||||
|
|
||||||
|
interface ProviderState {
|
||||||
|
state: string;
|
||||||
|
nonce: string;
|
||||||
|
redirect: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
export interface IdTokenResponse {
|
||||||
|
idToken: string;
|
||||||
|
claims: Record<string, any>;
|
||||||
|
raw: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OidcProvider(config: OidcConfig): Provider<{ id: JWTPayload; clientID: string }> {
|
||||||
|
const query = config.query || {};
|
||||||
|
const scopes = config.scopes || [];
|
||||||
|
|
||||||
|
const wk = lazy(() =>
|
||||||
|
fetch(config.issuer + '/.well-known/openid-configuration').then(async (r) => {
|
||||||
|
if (!r.ok) throw new Error(await r.text());
|
||||||
|
return r.json() as Promise<WellKnown>;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const jwks = lazy(() =>
|
||||||
|
wk()
|
||||||
|
.then((r) => r.jwks_uri)
|
||||||
|
.then(async (uri) => {
|
||||||
|
const r = await fetch(uri);
|
||||||
|
if (!r.ok) throw new Error(await r.text());
|
||||||
|
return createLocalJWKSet((await r.json()) as JSONWebKeySet);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: config.type || 'oidc',
|
||||||
|
init(routes, ctx) {
|
||||||
|
routes.get('/authorize', async (c) => {
|
||||||
|
const provider: ProviderState = {
|
||||||
|
state: crypto.randomUUID(),
|
||||||
|
nonce: crypto.randomUUID(),
|
||||||
|
redirect: getRelativeUrl(c, './callback')
|
||||||
|
};
|
||||||
|
await ctx.set(c, 'provider', 60 * 10, provider);
|
||||||
|
const authorization = new URL(await wk().then((r) => r.authorization_endpoint));
|
||||||
|
authorization.searchParams.set('client_id', config.clientID);
|
||||||
|
authorization.searchParams.set('response_type', 'id_token');
|
||||||
|
authorization.searchParams.set('response_mode', 'form_post');
|
||||||
|
authorization.searchParams.set('state', provider.state);
|
||||||
|
authorization.searchParams.set('nonce', provider.nonce);
|
||||||
|
authorization.searchParams.set('redirect_uri', provider.redirect);
|
||||||
|
authorization.searchParams.set('scope', ['openid', ...scopes].join(' '));
|
||||||
|
for (const [key, value] of Object.entries(query)) {
|
||||||
|
authorization.searchParams.set(key, value);
|
||||||
|
}
|
||||||
|
return c.redirect(authorization.toString());
|
||||||
|
});
|
||||||
|
|
||||||
|
routes.post('/callback', async (c) => {
|
||||||
|
const provider = await ctx.get<ProviderState>(c, 'provider');
|
||||||
|
if (!provider) return c.redirect(getRelativeUrl(c, './authorize'));
|
||||||
|
const body = await c.req.formData();
|
||||||
|
const error = body.get('error');
|
||||||
|
if (error)
|
||||||
|
throw new OauthError(
|
||||||
|
error.toString() as any,
|
||||||
|
body.get('error_description')?.toString() || ''
|
||||||
|
);
|
||||||
|
const idToken = body.get('id_token');
|
||||||
|
if (!idToken) throw new OauthError('invalid_request', 'Missing id_token');
|
||||||
|
const result = await jwtVerify(idToken.toString(), await jwks(), {
|
||||||
|
audience: config.clientID
|
||||||
|
});
|
||||||
|
if (result.payload.nonce !== provider.nonce) {
|
||||||
|
throw new OauthError('invalid_request', 'Invalid nonce');
|
||||||
|
}
|
||||||
|
return ctx.success(c, {
|
||||||
|
id: result.payload,
|
||||||
|
clientID: config.clientID
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
606
packages/auth/src/provider/password.ts
Normal file
606
packages/auth/src/provider/password.ts
Normal file
@@ -0,0 +1,606 @@
|
|||||||
|
import { v1 } from '@standard-schema/spec';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configures a provider that supports username and password authentication. This is usually
|
||||||
|
* paired with the `PasswordUI`.
|
||||||
|
*
|
||||||
|
* ```ts
|
||||||
|
* import { PasswordUI } from "@openauthjs/openauth/ui/password"
|
||||||
|
* import { PasswordProvider } from "@openauthjs/openauth/provider/password"
|
||||||
|
*
|
||||||
|
* export default issuer({
|
||||||
|
* providers: {
|
||||||
|
* password: PasswordProvider(
|
||||||
|
* PasswordUI({
|
||||||
|
* copy: {
|
||||||
|
* error_email_taken: "This email is already taken."
|
||||||
|
* },
|
||||||
|
* sendCode: (email, code) => console.log(email, code)
|
||||||
|
* })
|
||||||
|
* )
|
||||||
|
* },
|
||||||
|
* // ...
|
||||||
|
* })
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* Behind the scenes, the `PasswordProvider` expects callbacks that implements request handlers
|
||||||
|
* that generate the UI for the following.
|
||||||
|
*
|
||||||
|
* ```ts
|
||||||
|
* PasswordProvider({
|
||||||
|
* // ...
|
||||||
|
* login: (req, form, error) => Promise<Response>
|
||||||
|
* register: (req, state, form, error) => Promise<Response>
|
||||||
|
* change: (req, state, form, error) => Promise<Response>
|
||||||
|
* })
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* This allows you to create your own UI for each of these screens.
|
||||||
|
*
|
||||||
|
* @packageDocumentation
|
||||||
|
*/
|
||||||
|
import { UnknownStateError } from '../error.js';
|
||||||
|
import { generateUnbiasedDigits, timingSafeCompare } from '../random.js';
|
||||||
|
import { Storage } from '../storage/storage.js';
|
||||||
|
import { Provider } from './provider.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
export interface PasswordHasher<T> {
|
||||||
|
hash(password: string): Promise<T>;
|
||||||
|
verify(password: string, compare: T): Promise<boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PasswordConfig {
|
||||||
|
/**
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
length?: number;
|
||||||
|
/**
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
hasher?: PasswordHasher<any>;
|
||||||
|
/**
|
||||||
|
* The request handler to generate the UI for the login screen.
|
||||||
|
*
|
||||||
|
* Takes the standard [`Request`](https://developer.mozilla.org/en-US/docs/Web/API/Request)
|
||||||
|
* and optionally [`FormData`](https://developer.mozilla.org/en-US/docs/Web/API/FormData)
|
||||||
|
* ojects.
|
||||||
|
*
|
||||||
|
* In case of an error, this is called again with the `error`.
|
||||||
|
*
|
||||||
|
* Expects the [`Response`](https://developer.mozilla.org/en-US/docs/Web/API/Response) object
|
||||||
|
* in return.
|
||||||
|
*/
|
||||||
|
login: (req: Request, form?: FormData, error?: PasswordLoginError) => Promise<Response>;
|
||||||
|
/**
|
||||||
|
* The request handler to generate the UI for the register screen.
|
||||||
|
*
|
||||||
|
* Takes the standard [`Request`](https://developer.mozilla.org/en-US/docs/Web/API/Request)
|
||||||
|
* and optionally [`FormData`](https://developer.mozilla.org/en-US/docs/Web/API/FormData)
|
||||||
|
* ojects.
|
||||||
|
*
|
||||||
|
* Also passes in the current `state` of the flow and any `error` that occurred.
|
||||||
|
*
|
||||||
|
* Expects the [`Response`](https://developer.mozilla.org/en-US/docs/Web/API/Response) object
|
||||||
|
* in return.
|
||||||
|
*/
|
||||||
|
register: (
|
||||||
|
req: Request,
|
||||||
|
state: PasswordRegisterState,
|
||||||
|
form?: FormData,
|
||||||
|
error?: PasswordRegisterError
|
||||||
|
) => Promise<Response>;
|
||||||
|
/**
|
||||||
|
* The request handler to generate the UI for the change password screen.
|
||||||
|
*
|
||||||
|
* Takes the standard [`Request`](https://developer.mozilla.org/en-US/docs/Web/API/Request)
|
||||||
|
* and optionally [`FormData`](https://developer.mozilla.org/en-US/docs/Web/API/FormData)
|
||||||
|
* ojects.
|
||||||
|
*
|
||||||
|
* Also passes in the current `state` of the flow and any `error` that occurred.
|
||||||
|
*
|
||||||
|
* Expects the [`Response`](https://developer.mozilla.org/en-US/docs/Web/API/Response) object
|
||||||
|
* in return.
|
||||||
|
*/
|
||||||
|
change: (
|
||||||
|
req: Request,
|
||||||
|
state: PasswordChangeState,
|
||||||
|
form?: FormData,
|
||||||
|
error?: PasswordChangeError
|
||||||
|
) => Promise<Response>;
|
||||||
|
/**
|
||||||
|
* Callback to send the confirmation pin code to the user.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* {
|
||||||
|
* sendCode: async (email, code) => {
|
||||||
|
* // Send an email with the code
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
sendCode: (email: string, code: string) => Promise<void>;
|
||||||
|
/**
|
||||||
|
* Callback to validate the password on sign up and password reset.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* {
|
||||||
|
* validatePassword: (password) => {
|
||||||
|
* return password.length < 8 ? "Password must be at least 8 characters" : undefined
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
validatePassword?:
|
||||||
|
| v1.StandardSchema
|
||||||
|
| ((password: string) => Promise<string | undefined> | string | undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The states that can happen on the register screen.
|
||||||
|
*
|
||||||
|
* | State | Description |
|
||||||
|
* | ----- | ----------- |
|
||||||
|
* | `start` | The user is asked to enter their email address and password to start the flow. |
|
||||||
|
* | `code` | The user needs to enter the pin code to verify their email. |
|
||||||
|
*/
|
||||||
|
export type PasswordRegisterState =
|
||||||
|
| {
|
||||||
|
type: 'start';
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'code';
|
||||||
|
code: string;
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The errors that can happen on the register screen.
|
||||||
|
*
|
||||||
|
* | Error | Description |
|
||||||
|
* | ----- | ----------- |
|
||||||
|
* | `email_taken` | The email is already taken. |
|
||||||
|
* | `invalid_email` | The email is invalid. |
|
||||||
|
* | `invalid_code` | The code is invalid. |
|
||||||
|
* | `invalid_password` | The password is invalid. |
|
||||||
|
* | `password_mismatch` | The passwords do not match. |
|
||||||
|
*/
|
||||||
|
export type PasswordRegisterError =
|
||||||
|
| {
|
||||||
|
type: 'invalid_code';
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'email_taken';
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'invalid_email';
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'invalid_password';
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'password_mismatch';
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'validation_error';
|
||||||
|
message?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The state of the password change flow.
|
||||||
|
*
|
||||||
|
* | State | Description |
|
||||||
|
* | ----- | ----------- |
|
||||||
|
* | `start` | The user is asked to enter their email address to start the flow. |
|
||||||
|
* | `code` | The user needs to enter the pin code to verify their email. |
|
||||||
|
* | `update` | The user is asked to enter their new password and confirm it. |
|
||||||
|
*/
|
||||||
|
export type PasswordChangeState =
|
||||||
|
| {
|
||||||
|
type: 'start';
|
||||||
|
redirect: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'code';
|
||||||
|
code: string;
|
||||||
|
email: string;
|
||||||
|
redirect: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'update';
|
||||||
|
redirect: string;
|
||||||
|
email: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The errors that can happen on the change password screen.
|
||||||
|
*
|
||||||
|
* | Error | Description |
|
||||||
|
* | ----- | ----------- |
|
||||||
|
* | `invalid_email` | The email is invalid. |
|
||||||
|
* | `invalid_code` | The code is invalid. |
|
||||||
|
* | `invalid_password` | The password is invalid. |
|
||||||
|
* | `password_mismatch` | The passwords do not match. |
|
||||||
|
*/
|
||||||
|
export type PasswordChangeError =
|
||||||
|
| {
|
||||||
|
type: 'invalid_email';
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'invalid_code';
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'invalid_password';
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'password_mismatch';
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'validation_error';
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The errors that can happen on the login screen.
|
||||||
|
*
|
||||||
|
* | Error | Description |
|
||||||
|
* | ----- | ----------- |
|
||||||
|
* | `invalid_email` | The email is invalid. |
|
||||||
|
* | `invalid_password` | The password is invalid. |
|
||||||
|
*/
|
||||||
|
export type PasswordLoginError =
|
||||||
|
| {
|
||||||
|
type: 'invalid_password';
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'invalid_email';
|
||||||
|
};
|
||||||
|
|
||||||
|
export function PasswordProvider(config: PasswordConfig): Provider<{ email: string }> {
|
||||||
|
const hasher = config.hasher ?? ScryptHasher();
|
||||||
|
function generate() {
|
||||||
|
return generateUnbiasedDigits(6);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
type: 'password',
|
||||||
|
init(routes, ctx) {
|
||||||
|
routes.get('/authorize', async (c) => ctx.forward(c, await config.login(c.req.raw)));
|
||||||
|
|
||||||
|
routes.post('/authorize', async (c) => {
|
||||||
|
const fd = await c.req.formData();
|
||||||
|
async function error(err: PasswordLoginError) {
|
||||||
|
return ctx.forward(c, await config.login(c.req.raw, fd, err));
|
||||||
|
}
|
||||||
|
const email = fd.get('email')?.toString()?.toLowerCase();
|
||||||
|
if (!email) return error({ type: 'invalid_email' });
|
||||||
|
const hash = await Storage.get<HashedPassword>(ctx.storage, ['email', email, 'password']);
|
||||||
|
const password = fd.get('password')?.toString();
|
||||||
|
if (!password || !hash || !(await hasher.verify(password, hash)))
|
||||||
|
return error({ type: 'invalid_password' });
|
||||||
|
return ctx.success(
|
||||||
|
c,
|
||||||
|
{
|
||||||
|
email: email
|
||||||
|
},
|
||||||
|
{
|
||||||
|
invalidate: async (subject) => {
|
||||||
|
await Storage.set(ctx.storage, ['email', email, 'subject'], subject);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
routes.get('/register', async (c) => {
|
||||||
|
const state: PasswordRegisterState = {
|
||||||
|
type: 'start'
|
||||||
|
};
|
||||||
|
await ctx.set(c, 'provider', 60 * 60 * 24, state);
|
||||||
|
return ctx.forward(c, await config.register(c.req.raw, state));
|
||||||
|
});
|
||||||
|
|
||||||
|
routes.post('/register', async (c) => {
|
||||||
|
const fd = await c.req.formData();
|
||||||
|
const email = fd.get('email')?.toString()?.toLowerCase();
|
||||||
|
const action = fd.get('action')?.toString();
|
||||||
|
const provider = await ctx.get<PasswordRegisterState>(c, 'provider');
|
||||||
|
|
||||||
|
async function transition(next: PasswordRegisterState, err?: PasswordRegisterError) {
|
||||||
|
await ctx.set<PasswordRegisterState>(c, 'provider', 60 * 60 * 24, next);
|
||||||
|
return ctx.forward(c, await config.register(c.req.raw, next, fd, err));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === 'register' && provider.type === 'start') {
|
||||||
|
const password = fd.get('password')?.toString();
|
||||||
|
const repeat = fd.get('repeat')?.toString();
|
||||||
|
if (!email) return transition(provider, { type: 'invalid_email' });
|
||||||
|
if (!password) return transition(provider, { type: 'invalid_password' });
|
||||||
|
if (password !== repeat) return transition(provider, { type: 'password_mismatch' });
|
||||||
|
if (config.validatePassword) {
|
||||||
|
let validationError: string | undefined;
|
||||||
|
try {
|
||||||
|
if (typeof config.validatePassword === 'function') {
|
||||||
|
validationError = await config.validatePassword(password);
|
||||||
|
} else {
|
||||||
|
const res = await config.validatePassword['~standard'].validate(password);
|
||||||
|
|
||||||
|
if (res.issues?.length) {
|
||||||
|
throw new Error(res.issues.map((issue) => issue.message).join(', '));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
validationError = error instanceof Error ? error.message : undefined;
|
||||||
|
}
|
||||||
|
if (validationError)
|
||||||
|
return transition(provider, {
|
||||||
|
type: 'validation_error',
|
||||||
|
message: validationError
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const existing = await Storage.get(ctx.storage, ['email', email, 'password']);
|
||||||
|
if (existing) return transition(provider, { type: 'email_taken' });
|
||||||
|
const code = generate();
|
||||||
|
await config.sendCode(email, code);
|
||||||
|
return transition({
|
||||||
|
type: 'code',
|
||||||
|
code,
|
||||||
|
password: await hasher.hash(password),
|
||||||
|
email
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === 'register' && provider.type === 'code') {
|
||||||
|
const code = generate();
|
||||||
|
await config.sendCode(provider.email, code);
|
||||||
|
return transition({
|
||||||
|
type: 'code',
|
||||||
|
code,
|
||||||
|
password: provider.password,
|
||||||
|
email: provider.email
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === 'verify' && provider.type === 'code') {
|
||||||
|
const code = fd.get('code')?.toString();
|
||||||
|
if (!code || !timingSafeCompare(code, provider.code))
|
||||||
|
return transition(provider, { type: 'invalid_code' });
|
||||||
|
const existing = await Storage.get(ctx.storage, ['email', provider.email, 'password']);
|
||||||
|
if (existing) return transition({ type: 'start' }, { type: 'email_taken' });
|
||||||
|
await Storage.set(ctx.storage, ['email', provider.email, 'password'], provider.password);
|
||||||
|
return ctx.success(c, {
|
||||||
|
email: provider.email
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return transition({ type: 'start' });
|
||||||
|
});
|
||||||
|
|
||||||
|
routes.get('/change', async (c) => {
|
||||||
|
let redirect = c.req.query('redirect_uri') || getRelativeUrl(c, './authorize');
|
||||||
|
const state: PasswordChangeState = {
|
||||||
|
type: 'start',
|
||||||
|
redirect
|
||||||
|
};
|
||||||
|
await ctx.set(c, 'provider', 60 * 60 * 24, state);
|
||||||
|
return ctx.forward(c, await config.change(c.req.raw, state));
|
||||||
|
});
|
||||||
|
|
||||||
|
routes.post('/change', async (c) => {
|
||||||
|
const fd = await c.req.formData();
|
||||||
|
const action = fd.get('action')?.toString();
|
||||||
|
const provider = await ctx.get<PasswordChangeState>(c, 'provider');
|
||||||
|
if (!provider) throw new UnknownStateError();
|
||||||
|
|
||||||
|
async function transition(next: PasswordChangeState, err?: PasswordChangeError) {
|
||||||
|
await ctx.set<PasswordChangeState>(c, 'provider', 60 * 60 * 24, next);
|
||||||
|
return ctx.forward(c, await config.change(c.req.raw, next, fd, err));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === 'code') {
|
||||||
|
const email = fd.get('email')?.toString()?.toLowerCase();
|
||||||
|
if (!email)
|
||||||
|
return transition(
|
||||||
|
{ type: 'start', redirect: provider.redirect },
|
||||||
|
{ type: 'invalid_email' }
|
||||||
|
);
|
||||||
|
const code = generate();
|
||||||
|
await config.sendCode(email, code);
|
||||||
|
|
||||||
|
return transition({
|
||||||
|
type: 'code',
|
||||||
|
code,
|
||||||
|
email,
|
||||||
|
redirect: provider.redirect
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === 'verify' && provider.type === 'code') {
|
||||||
|
const code = fd.get('code')?.toString();
|
||||||
|
if (!code || !timingSafeCompare(code, provider.code))
|
||||||
|
return transition(provider, { type: 'invalid_code' });
|
||||||
|
return transition({
|
||||||
|
type: 'update',
|
||||||
|
email: provider.email,
|
||||||
|
redirect: provider.redirect
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === 'update' && provider.type === 'update') {
|
||||||
|
const existing = await Storage.get(ctx.storage, ['email', provider.email, 'password']);
|
||||||
|
if (!existing) return c.redirect(provider.redirect, 302);
|
||||||
|
|
||||||
|
const password = fd.get('password')?.toString();
|
||||||
|
const repeat = fd.get('repeat')?.toString();
|
||||||
|
if (!password) return transition(provider, { type: 'invalid_password' });
|
||||||
|
if (password !== repeat) return transition(provider, { type: 'password_mismatch' });
|
||||||
|
|
||||||
|
if (config.validatePassword) {
|
||||||
|
let validationError: string | undefined;
|
||||||
|
try {
|
||||||
|
if (typeof config.validatePassword === 'function') {
|
||||||
|
validationError = await config.validatePassword(password);
|
||||||
|
} else {
|
||||||
|
const res = await config.validatePassword['~standard'].validate(password);
|
||||||
|
|
||||||
|
if (res.issues?.length) {
|
||||||
|
throw new Error(res.issues.map((issue) => issue.message).join(', '));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
validationError = error instanceof Error ? error.message : undefined;
|
||||||
|
}
|
||||||
|
if (validationError)
|
||||||
|
return transition(provider, {
|
||||||
|
type: 'validation_error',
|
||||||
|
message: validationError
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await Storage.set(
|
||||||
|
ctx.storage,
|
||||||
|
['email', provider.email, 'password'],
|
||||||
|
await hasher.hash(password)
|
||||||
|
);
|
||||||
|
const subject = await Storage.get<string>(ctx.storage, [
|
||||||
|
'email',
|
||||||
|
provider.email,
|
||||||
|
'subject'
|
||||||
|
]);
|
||||||
|
if (subject) await ctx.invalidate(subject);
|
||||||
|
|
||||||
|
return c.redirect(provider.redirect, 302);
|
||||||
|
}
|
||||||
|
|
||||||
|
return transition({ type: 'start', redirect: provider.redirect });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
import { TextEncoder } from 'node:util';
|
||||||
|
|
||||||
|
import * as jose from 'jose';
|
||||||
|
|
||||||
|
interface HashedPassword {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
export function PBKDF2Hasher(opts?: { iterations?: number }): PasswordHasher<{
|
||||||
|
hash: string;
|
||||||
|
salt: string;
|
||||||
|
iterations: number;
|
||||||
|
}> {
|
||||||
|
const iterations = opts?.iterations ?? 600000;
|
||||||
|
return {
|
||||||
|
async hash(password) {
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const bytes = encoder.encode(password);
|
||||||
|
const salt = crypto.getRandomValues(new Uint8Array(16));
|
||||||
|
const keyMaterial = await crypto.subtle.importKey('raw', bytes, 'PBKDF2', false, [
|
||||||
|
'deriveBits'
|
||||||
|
]);
|
||||||
|
const hash = await crypto.subtle.deriveBits(
|
||||||
|
{
|
||||||
|
name: 'PBKDF2',
|
||||||
|
hash: 'SHA-256',
|
||||||
|
salt: salt,
|
||||||
|
iterations
|
||||||
|
},
|
||||||
|
keyMaterial,
|
||||||
|
256
|
||||||
|
);
|
||||||
|
const hashBase64 = jose.base64url.encode(new Uint8Array(hash));
|
||||||
|
const saltBase64 = jose.base64url.encode(salt);
|
||||||
|
return {
|
||||||
|
hash: hashBase64,
|
||||||
|
salt: saltBase64,
|
||||||
|
iterations
|
||||||
|
};
|
||||||
|
},
|
||||||
|
async verify(password, compare) {
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const passwordBytes = encoder.encode(password);
|
||||||
|
const salt = jose.base64url.decode(compare.salt);
|
||||||
|
const params = {
|
||||||
|
name: 'PBKDF2',
|
||||||
|
hash: 'SHA-256',
|
||||||
|
salt,
|
||||||
|
iterations: compare.iterations
|
||||||
|
};
|
||||||
|
const keyMaterial = await crypto.subtle.importKey('raw', passwordBytes, 'PBKDF2', false, [
|
||||||
|
'deriveBits'
|
||||||
|
]);
|
||||||
|
const hash = await crypto.subtle.deriveBits(params, keyMaterial, 256);
|
||||||
|
const hashBase64 = jose.base64url.encode(new Uint8Array(hash));
|
||||||
|
return hashBase64 === compare.hash;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
import { timingSafeEqual, randomBytes, scrypt } from 'node:crypto';
|
||||||
|
|
||||||
|
import { getRelativeUrl } from '../util.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
export function ScryptHasher(opts?: { N?: number; r?: number; p?: number }): PasswordHasher<{
|
||||||
|
hash: string;
|
||||||
|
salt: string;
|
||||||
|
N: number;
|
||||||
|
r: number;
|
||||||
|
p: number;
|
||||||
|
}> {
|
||||||
|
const N = opts?.N ?? 16384;
|
||||||
|
const r = opts?.r ?? 8;
|
||||||
|
const p = opts?.p ?? 1;
|
||||||
|
|
||||||
|
return {
|
||||||
|
async hash(password) {
|
||||||
|
const salt = randomBytes(16);
|
||||||
|
const keyLength = 32; // 256 bits
|
||||||
|
|
||||||
|
const derivedKey = await new Promise<Buffer>((resolve, reject) => {
|
||||||
|
scrypt(password, salt, keyLength, { N, r, p }, (err, derivedKey) => {
|
||||||
|
if (err) reject(err);
|
||||||
|
else resolve(derivedKey);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const hashBase64 = derivedKey.toString('base64');
|
||||||
|
const saltBase64 = salt.toString('base64');
|
||||||
|
|
||||||
|
return {
|
||||||
|
hash: hashBase64,
|
||||||
|
salt: saltBase64,
|
||||||
|
N,
|
||||||
|
r,
|
||||||
|
p
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
async verify(password, compare) {
|
||||||
|
const salt = Buffer.from(compare.salt, 'base64');
|
||||||
|
const keyLength = 32; // 256 bits
|
||||||
|
|
||||||
|
const derivedKey = await new Promise<Buffer>((resolve, reject) => {
|
||||||
|
scrypt(
|
||||||
|
password,
|
||||||
|
salt,
|
||||||
|
keyLength,
|
||||||
|
{ N: compare.N, r: compare.r, p: compare.p },
|
||||||
|
(err, derivedKey) => {
|
||||||
|
if (err) reject(err);
|
||||||
|
else resolve(derivedKey);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
return timingSafeEqual(derivedKey, Buffer.from(compare.hash, 'base64'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
34
packages/auth/src/provider/provider.ts
Normal file
34
packages/auth/src/provider/provider.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import type { Context, Hono } from 'hono';
|
||||||
|
|
||||||
|
import { StorageAdapter } from '../storage/storage.js';
|
||||||
|
|
||||||
|
export type ProviderRoute = Hono;
|
||||||
|
|
||||||
|
export interface Provider<Properties = any> {
|
||||||
|
type: string;
|
||||||
|
init: (route: ProviderRoute, options: ProviderOptions<Properties>) => void;
|
||||||
|
client?: (input: {
|
||||||
|
clientID: string;
|
||||||
|
clientSecret: string;
|
||||||
|
params: Record<string, string>;
|
||||||
|
}) => Promise<Properties>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProviderOptions<Properties> {
|
||||||
|
name: string;
|
||||||
|
success: (
|
||||||
|
ctx: Context,
|
||||||
|
properties: Properties,
|
||||||
|
opts?: {
|
||||||
|
invalidate?: (subject: string) => Promise<void>;
|
||||||
|
}
|
||||||
|
) => Promise<Response>;
|
||||||
|
forward: (ctx: Context, response: Response) => Response;
|
||||||
|
set: <T>(ctx: Context, key: string, maxAge: number, value: T) => Promise<void>;
|
||||||
|
get: <T>(ctx: Context, key: string) => Promise<T>;
|
||||||
|
unset: (ctx: Context, key: string) => Promise<void>;
|
||||||
|
invalidate: (subject: string) => Promise<void>;
|
||||||
|
storage: StorageAdapter;
|
||||||
|
}
|
||||||
|
export class ProviderError extends Error {}
|
||||||
|
export class ProviderUnknownError extends ProviderError {}
|
||||||
67
packages/auth/src/provider/slack.ts
Normal file
67
packages/auth/src/provider/slack.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
/**
|
||||||
|
* Use this provider to authenticate with Slack.
|
||||||
|
*
|
||||||
|
* ```ts {5-10}
|
||||||
|
* import { SlackProvider } from "@openauthjs/openauth/provider/slack"
|
||||||
|
*
|
||||||
|
* export default issuer({
|
||||||
|
* providers: {
|
||||||
|
* slack: SlackProvider({
|
||||||
|
* team: "T1234567890",
|
||||||
|
* clientID: "1234567890",
|
||||||
|
* clientSecret: "0987654321",
|
||||||
|
* scopes: ["openid", "email", "profile"]
|
||||||
|
* })
|
||||||
|
* }
|
||||||
|
* })
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @packageDocumentation
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Oauth2Provider, Oauth2WrappedConfig } from './oauth2.js';
|
||||||
|
|
||||||
|
export interface SlackConfig extends Oauth2WrappedConfig {
|
||||||
|
/**
|
||||||
|
* The workspace the user is intending to authenticate.
|
||||||
|
*
|
||||||
|
* If that workspace has been previously authenticated, the user will be signed in directly,
|
||||||
|
* bypassing the consent screen.
|
||||||
|
*/
|
||||||
|
team: string;
|
||||||
|
/**
|
||||||
|
* The scopes to request from the user.
|
||||||
|
*
|
||||||
|
* | Scope | Description |
|
||||||
|
* |-|-|
|
||||||
|
* | `email` | Grants permission to access the user's email address. |
|
||||||
|
* | `profile` | Grants permission to access the user's profile information. |
|
||||||
|
* | `openid` | Grants permission to use OpenID Connect to verify the user's identity. |
|
||||||
|
*/
|
||||||
|
scopes: ('email' | 'profile' | 'openid')[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a [Slack OAuth2 provider](https://api.slack.com/authentication/sign-in-with-slack).
|
||||||
|
*
|
||||||
|
* @param {SlackConfig} config - The config for the provider.
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* SlackProvider({
|
||||||
|
* team: "T1234567890",
|
||||||
|
* clientID: "1234567890",
|
||||||
|
* clientSecret: "0987654321",
|
||||||
|
* scopes: ["openid", "email", "profile"]
|
||||||
|
* })
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function SlackProvider(config: SlackConfig) {
|
||||||
|
return Oauth2Provider({
|
||||||
|
...config,
|
||||||
|
type: 'slack',
|
||||||
|
endpoint: {
|
||||||
|
authorization: 'https://slack.com/openid/connect/authorize',
|
||||||
|
token: 'https://slack.com/api/openid.connect.token'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
45
packages/auth/src/provider/spotify.ts
Normal file
45
packages/auth/src/provider/spotify.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
/**
|
||||||
|
* Use this provider to authenticate with Spotify.
|
||||||
|
*
|
||||||
|
* ```ts {5-8}
|
||||||
|
* import { SpotifyProvider } from "@openauthjs/openauth/provider/spotify"
|
||||||
|
*
|
||||||
|
* export default issuer({
|
||||||
|
* providers: {
|
||||||
|
* spotify: SpotifyProvider({
|
||||||
|
* clientID: "1234567890",
|
||||||
|
* clientSecret: "0987654321"
|
||||||
|
* })
|
||||||
|
* }
|
||||||
|
* })
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @packageDocumentation
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Oauth2Provider, type Oauth2WrappedConfig } from './oauth2.js';
|
||||||
|
|
||||||
|
export interface SpotifyConfig extends Oauth2WrappedConfig {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a Spotify OAuth2 provider.
|
||||||
|
*
|
||||||
|
* @param config - The config for the provider.
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* SpotifyProvider({
|
||||||
|
* clientID: "1234567890",
|
||||||
|
* clientSecret: "0987654321"
|
||||||
|
* })
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function SpotifyProvider(config: SpotifyConfig) {
|
||||||
|
return Oauth2Provider({
|
||||||
|
...config,
|
||||||
|
type: 'spotify',
|
||||||
|
endpoint: {
|
||||||
|
authorization: 'https://accounts.spotify.com/authorize',
|
||||||
|
token: 'https://accounts.spotify.com/api/token'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
53
packages/auth/src/provider/ssh.ts
Normal file
53
packages/auth/src/provider/ssh.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import type { Context } from 'hono';
|
||||||
|
|
||||||
|
import type { Provider } from './provider.js';
|
||||||
|
|
||||||
|
export interface SshProviderConfig {
|
||||||
|
sshAuthKey: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SshLoginBody {
|
||||||
|
fingerprint: string;
|
||||||
|
steamId: string;
|
||||||
|
username?: string;
|
||||||
|
profile?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SshProvider(config: SshProviderConfig): Provider<{
|
||||||
|
fingerprint: string;
|
||||||
|
steamId: string;
|
||||||
|
username?: string;
|
||||||
|
profile?: Record<string, unknown>;
|
||||||
|
}> {
|
||||||
|
return {
|
||||||
|
type: 'ssh',
|
||||||
|
init(routes, ctx) {
|
||||||
|
routes.post('/login', async (c: Context) => {
|
||||||
|
const authHeader = c.req.header('Authorization');
|
||||||
|
if (!authHeader) {
|
||||||
|
return c.json({ error: 'Missing Authorization header' }, 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const bearer = authHeader.split(' ')[1];
|
||||||
|
if (bearer !== config.sshAuthKey) {
|
||||||
|
return c.json({ error: 'Invalid authorization token' }, 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = (await c.req.json()) as SshLoginBody;
|
||||||
|
if (!body.fingerprint) {
|
||||||
|
return c.json({ error: 'Fingerprint is required' }, 400);
|
||||||
|
}
|
||||||
|
if (!body.steamId || !/^\d{17}$/.test(body.steamId)) {
|
||||||
|
return c.json({ error: 'steamId is required and must be a 17-digit Steam ID' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ctx.success(c, {
|
||||||
|
fingerprint: body.fingerprint,
|
||||||
|
steamId: body.steamId,
|
||||||
|
username: body.username,
|
||||||
|
profile: body.profile
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
52
packages/auth/src/provider/steam.ts
Normal file
52
packages/auth/src/provider/steam.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import { getRelativeUrl } from '../util.js';
|
||||||
|
import { Provider } from './provider.js';
|
||||||
|
|
||||||
|
const STEAM_OPENID_URL = 'https://steamcommunity.com/openid/login';
|
||||||
|
|
||||||
|
export function SteamProvider(): Provider<{ steamid: string }> {
|
||||||
|
return {
|
||||||
|
type: 'steam',
|
||||||
|
init(routes, ctx) {
|
||||||
|
routes.get('/authorize', async (c) => {
|
||||||
|
const returnUrl = getRelativeUrl(c, './callback');
|
||||||
|
const openidURL =
|
||||||
|
`${STEAM_OPENID_URL}?` +
|
||||||
|
`openid.ns=${encodeURIComponent('http://specs.openid.net/auth/2.0')}&` +
|
||||||
|
`openid.mode=checkid_setup&` +
|
||||||
|
`openid.return_to=${encodeURIComponent(returnUrl)}&` +
|
||||||
|
`openid.realm=${encodeURIComponent(new URL(c.req.url).origin)}&` +
|
||||||
|
`openid.identity=${encodeURIComponent('http://specs.openid.net/auth/2.0/identifier_select')}&` +
|
||||||
|
`openid.claimed_id=${encodeURIComponent('http://specs.openid.net/auth/2.0/identifier_select')}`;
|
||||||
|
return c.redirect(openidURL);
|
||||||
|
});
|
||||||
|
|
||||||
|
routes.get('/callback', async (c) => {
|
||||||
|
const url = new URL(c.req.url);
|
||||||
|
const params = Object.fromEntries(url.searchParams.entries());
|
||||||
|
|
||||||
|
const verifyRes = await fetch(STEAM_OPENID_URL, {
|
||||||
|
method: 'POST',
|
||||||
|
body: new URLSearchParams({
|
||||||
|
...params,
|
||||||
|
'openid.mode': 'check_authentication'
|
||||||
|
}),
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const verifyText = await verifyRes.text();
|
||||||
|
if (!verifyText.includes('is_valid:true')) {
|
||||||
|
throw new Error('Steam OpenID validation failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
const steamid = params['openid.claimed_id']?.split('/').pop();
|
||||||
|
if (!steamid) {
|
||||||
|
throw new Error('Steam ID not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
return ctx.success(c, { steamid });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
45
packages/auth/src/provider/twitch.ts
Normal file
45
packages/auth/src/provider/twitch.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
/**
|
||||||
|
* Use this provider to authenticate with Twitch.
|
||||||
|
*
|
||||||
|
* ```ts {5-8}
|
||||||
|
* import { TwitchProvider } from "@openauthjs/openauth/provider/twitch"
|
||||||
|
*
|
||||||
|
* export default issuer({
|
||||||
|
* providers: {
|
||||||
|
* twitch: TwitchProvider({
|
||||||
|
* clientID: "1234567890",
|
||||||
|
* clientSecret: "0987654321"
|
||||||
|
* })
|
||||||
|
* }
|
||||||
|
* })
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @packageDocumentation
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Oauth2Provider, Oauth2WrappedConfig } from './oauth2.js';
|
||||||
|
|
||||||
|
export interface TwitchConfig extends Oauth2WrappedConfig {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a Twitch OAuth2 provider.
|
||||||
|
*
|
||||||
|
* @param config - The config for the provider.
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* TwitchProvider({
|
||||||
|
* clientID: "1234567890",
|
||||||
|
* clientSecret: "0987654321"
|
||||||
|
* })
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function TwitchProvider(config: TwitchConfig) {
|
||||||
|
return Oauth2Provider({
|
||||||
|
type: 'twitch',
|
||||||
|
...config,
|
||||||
|
endpoint: {
|
||||||
|
authorization: 'https://id.twitch.tv/oauth2/authorize',
|
||||||
|
token: 'https://id.twitch.tv/oauth2/token'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
46
packages/auth/src/provider/x.ts
Normal file
46
packages/auth/src/provider/x.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
/**
|
||||||
|
* Use this provider to authenticate with X.com.
|
||||||
|
*
|
||||||
|
* ```ts {5-8}
|
||||||
|
* import { XProvider } from "@openauthjs/openauth/provider/x"
|
||||||
|
*
|
||||||
|
* export default issuer({
|
||||||
|
* providers: {
|
||||||
|
* x: XProvider({
|
||||||
|
* clientID: "1234567890",
|
||||||
|
* clientSecret: "0987654321"
|
||||||
|
* })
|
||||||
|
* }
|
||||||
|
* })
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @packageDocumentation
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Oauth2Provider, Oauth2WrappedConfig } from './oauth2.js';
|
||||||
|
|
||||||
|
export interface XProviderConfig extends Oauth2WrappedConfig {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a X.com OAuth2 provider.
|
||||||
|
*
|
||||||
|
* @param config - The config for the provider.
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* XProvider({
|
||||||
|
* clientID: "1234567890",
|
||||||
|
* clientSecret: "0987654321"
|
||||||
|
* })
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function XProvider(config: XProviderConfig) {
|
||||||
|
return Oauth2Provider({
|
||||||
|
...config,
|
||||||
|
type: 'x',
|
||||||
|
endpoint: {
|
||||||
|
authorization: 'https://twitter.com/i/oauth2/authorize',
|
||||||
|
token: 'https://api.x.com/2/oauth2/token'
|
||||||
|
},
|
||||||
|
pkce: true
|
||||||
|
});
|
||||||
|
}
|
||||||
45
packages/auth/src/provider/yahoo.ts
Normal file
45
packages/auth/src/provider/yahoo.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
/**
|
||||||
|
* Use this provider to authenticate with Yahoo.
|
||||||
|
*
|
||||||
|
* ```ts {5-8}
|
||||||
|
* import { YahooProvider } from "@openauthjs/openauth/provider/yahoo"
|
||||||
|
*
|
||||||
|
* export default issuer({
|
||||||
|
* providers: {
|
||||||
|
* yahoo: YahooProvider({
|
||||||
|
* clientID: "1234567890",
|
||||||
|
* clientSecret: "0987654321"
|
||||||
|
* })
|
||||||
|
* }
|
||||||
|
* })
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @packageDocumentation
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Oauth2Provider, Oauth2WrappedConfig } from './oauth2.js';
|
||||||
|
|
||||||
|
export interface YahooConfig extends Oauth2WrappedConfig {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a Yahoo OAuth2 provider.
|
||||||
|
*
|
||||||
|
* @param config - The config for the provider.
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* YahooProvider({
|
||||||
|
* clientID: "1234567890",
|
||||||
|
* clientSecret: "0987654321"
|
||||||
|
* })
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function YahooProvider(config: YahooConfig) {
|
||||||
|
return Oauth2Provider({
|
||||||
|
...config,
|
||||||
|
type: 'yahoo',
|
||||||
|
endpoint: {
|
||||||
|
authorization: 'https://api.login.yahoo.com/oauth2/request_auth',
|
||||||
|
token: 'https://api.login.yahoo.com/oauth2/get_token'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
24
packages/auth/src/random.ts
Normal file
24
packages/auth/src/random.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { timingSafeEqual } from 'node:crypto';
|
||||||
|
|
||||||
|
export function generateUnbiasedDigits(length: number): string {
|
||||||
|
const result: number[] = [];
|
||||||
|
while (result.length < length) {
|
||||||
|
const buffer = crypto.getRandomValues(new Uint8Array(length * 2));
|
||||||
|
for (const byte of buffer) {
|
||||||
|
if (byte < 250 && result.length < length) {
|
||||||
|
result.push(byte % 10);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function timingSafeCompare(a: string, b: string): boolean {
|
||||||
|
if (typeof a !== 'string' || typeof b !== 'string') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (a.length !== b.length) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return timingSafeEqual(Buffer.from(a), Buffer.from(b));
|
||||||
|
}
|
||||||
53
packages/auth/src/storage/aws.ts
Normal file
53
packages/auth/src/storage/aws.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import { AwsClient } from 'aws4fetch';
|
||||||
|
|
||||||
|
interface EC2Credentials {
|
||||||
|
AccessKeyId: string;
|
||||||
|
SecretAccessKey: string;
|
||||||
|
Token: string;
|
||||||
|
Expiration: string;
|
||||||
|
Type: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cachedCredentials: EC2Credentials | null = null;
|
||||||
|
|
||||||
|
async function getCredentials(url: string): Promise<EC2Credentials> {
|
||||||
|
if (cachedCredentials) {
|
||||||
|
const currentTime = new Date();
|
||||||
|
const fiveMinutesFromNow = new Date(currentTime.getTime() + 5 * 60000);
|
||||||
|
const expirationTime = new Date(cachedCredentials.Expiration);
|
||||||
|
if (expirationTime > fiveMinutesFromNow) {
|
||||||
|
return cachedCredentials;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const credentials = (await fetch(url).then((res) => res.json())) as EC2Credentials;
|
||||||
|
cachedCredentials = credentials;
|
||||||
|
return credentials;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function client(): Promise<AwsClient> {
|
||||||
|
if (process.env.AWS_ACCESS_KEY_ID && process.env.AWS_SECRET_ACCESS_KEY) {
|
||||||
|
return new AwsClient({
|
||||||
|
accessKeyId: process.env.AWS_ACCESS_KEY_ID,
|
||||||
|
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
|
||||||
|
sessionToken: process.env.AWS_SESSION_TOKEN,
|
||||||
|
region: process.env.AWS_REGION
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.env.AWS_CONTAINER_CREDENTIALS_RELATIVE_URI) {
|
||||||
|
const credentials = await getCredentials(
|
||||||
|
'http://169.254.170.2' + process.env.AWS_CONTAINER_CREDENTIALS_RELATIVE_URI
|
||||||
|
);
|
||||||
|
return new AwsClient({
|
||||||
|
accessKeyId: credentials.AccessKeyId,
|
||||||
|
secretAccessKey: credentials.SecretAccessKey,
|
||||||
|
sessionToken: credentials.Token,
|
||||||
|
region: process.env.AWS_REGION
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('No AWS credentials found');
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AwsOptions = Exclude<Parameters<AwsClient['fetch']>[1], null | undefined>['aws'];
|
||||||
76
packages/auth/src/storage/cloudflare.ts
Normal file
76
packages/auth/src/storage/cloudflare.ts
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
/**
|
||||||
|
* Configure OpenAuth to use [Cloudflare KV](https://developers.cloudflare.com/kv/) as a
|
||||||
|
* storage adapter.
|
||||||
|
*
|
||||||
|
* ```ts
|
||||||
|
* import { CloudflareStorage } from "@openauthjs/openauth/storage/cloudflare"
|
||||||
|
*
|
||||||
|
* const storage = CloudflareStorage({
|
||||||
|
* namespace: "my-namespace"
|
||||||
|
* })
|
||||||
|
*
|
||||||
|
*
|
||||||
|
* export default issuer({
|
||||||
|
* storage,
|
||||||
|
* // ...
|
||||||
|
* })
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @packageDocumentation
|
||||||
|
*/
|
||||||
|
import type { KVNamespace } from '@cloudflare/workers-types';
|
||||||
|
|
||||||
|
import { joinKey, splitKey, StorageAdapter } from './storage.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configure the Cloudflare KV store that's created.
|
||||||
|
*/
|
||||||
|
export interface CloudflareStorageOptions {
|
||||||
|
namespace: KVNamespace;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Creates a Cloudflare KV store.
|
||||||
|
* @param options - The config for the adapter.
|
||||||
|
*/
|
||||||
|
export function CloudflareStorage(options: CloudflareStorageOptions): StorageAdapter {
|
||||||
|
return {
|
||||||
|
async get(key: string[]) {
|
||||||
|
const value = await options.namespace.get(joinKey(key), 'json');
|
||||||
|
if (!value) return;
|
||||||
|
return value as Record<string, any>;
|
||||||
|
},
|
||||||
|
|
||||||
|
async set(key: string[], value: any, expiry?: Date) {
|
||||||
|
await options.namespace.put(joinKey(key), JSON.stringify(value), {
|
||||||
|
expirationTtl: expiry
|
||||||
|
? Math.max(Math.floor((expiry.getTime() - Date.now()) / 1000), 60)
|
||||||
|
: undefined
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async remove(key: string[]) {
|
||||||
|
await options.namespace.delete(joinKey(key));
|
||||||
|
},
|
||||||
|
|
||||||
|
async *scan(prefix: string[]) {
|
||||||
|
let cursor: string | undefined;
|
||||||
|
while (true) {
|
||||||
|
const result = await options.namespace.list({
|
||||||
|
prefix: joinKey([...prefix, '']),
|
||||||
|
cursor
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const key of result.keys) {
|
||||||
|
const value = await options.namespace.get(key.name, 'json');
|
||||||
|
if (value !== null) {
|
||||||
|
yield [splitKey(key.name), value];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (result.list_complete) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
cursor = result.cursor;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
189
packages/auth/src/storage/dynamo.ts
Normal file
189
packages/auth/src/storage/dynamo.ts
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
/**
|
||||||
|
* Configure OpenAuth to use [DynamoDB](https://aws.amazon.com/dynamodb/) as a storage adapter.
|
||||||
|
*
|
||||||
|
* ```ts
|
||||||
|
* import { DynamoStorage } from "@openauthjs/openauth/storage/dynamo"
|
||||||
|
*
|
||||||
|
* const storage = DynamoStorage({
|
||||||
|
* table: "my-table",
|
||||||
|
* pk: "pk",
|
||||||
|
* sk: "sk"
|
||||||
|
* })
|
||||||
|
*
|
||||||
|
* export default issuer({
|
||||||
|
* storage,
|
||||||
|
* // ...
|
||||||
|
* })
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @packageDocumentation
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { client } from './aws.js';
|
||||||
|
import { joinKey, StorageAdapter } from './storage.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configure the DynamoDB table that's created.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* {
|
||||||
|
* table: "my-table",
|
||||||
|
* pk: "pk",
|
||||||
|
* sk: "sk"
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export interface DynamoStorageOptions {
|
||||||
|
/**
|
||||||
|
* The name of the DynamoDB table.
|
||||||
|
*/
|
||||||
|
table: string;
|
||||||
|
/**
|
||||||
|
* The primary key column name.
|
||||||
|
* @default "pk"
|
||||||
|
*/
|
||||||
|
pk?: string;
|
||||||
|
/**
|
||||||
|
* The sort key column name.
|
||||||
|
* @default "sk"
|
||||||
|
*/
|
||||||
|
sk?: string;
|
||||||
|
/**
|
||||||
|
* Endpoint URL for the DynamoDB service. Useful for local testing.
|
||||||
|
* @default "https://dynamodb.{region}.amazonaws.com"
|
||||||
|
*/
|
||||||
|
endpoint?: string;
|
||||||
|
/**
|
||||||
|
* The name of the time to live attribute.
|
||||||
|
* @default "expiry"
|
||||||
|
*/
|
||||||
|
ttl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a DynamoDB store.
|
||||||
|
* @param options - The config for the adapter.
|
||||||
|
*/
|
||||||
|
export function DynamoStorage(options: DynamoStorageOptions): StorageAdapter {
|
||||||
|
const pk = options.pk || 'pk';
|
||||||
|
const sk = options.sk || 'sk';
|
||||||
|
const ttl = options.ttl || 'expiry';
|
||||||
|
const tableName = options.table;
|
||||||
|
|
||||||
|
function parseKey(key: string[]) {
|
||||||
|
if (key.length === 2) {
|
||||||
|
return {
|
||||||
|
pk: key[0],
|
||||||
|
sk: key[1]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
pk: joinKey(key.slice(0, 2)),
|
||||||
|
sk: joinKey(key.slice(2))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function dynamo(action: string, payload: any) {
|
||||||
|
const c = await client();
|
||||||
|
const endpoint = options.endpoint || `https://dynamodb.${c.region}.amazonaws.com`;
|
||||||
|
const response = await c.fetch(endpoint, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/x-amz-json-1.0',
|
||||||
|
'X-Amz-Target': `DynamoDB_20120810.${action}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`DynamoDB request failed: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json() as Promise<any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
async get(key: string[]) {
|
||||||
|
const { pk: keyPk, sk: keySk } = parseKey(key);
|
||||||
|
const params = {
|
||||||
|
TableName: tableName,
|
||||||
|
Key: {
|
||||||
|
[pk]: { S: keyPk },
|
||||||
|
[sk]: { S: keySk }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const result = await dynamo('GetItem', params);
|
||||||
|
if (!result.Item) return;
|
||||||
|
if (result.Item[ttl] && result.Item[ttl].N < Date.now() / 1000) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return JSON.parse(result.Item.value.S);
|
||||||
|
},
|
||||||
|
|
||||||
|
async set(key: string[], value: any, expiry?: Date) {
|
||||||
|
const parsed = parseKey(key);
|
||||||
|
const params = {
|
||||||
|
TableName: tableName,
|
||||||
|
Item: {
|
||||||
|
[pk]: { S: parsed.pk },
|
||||||
|
[sk]: { S: parsed.sk },
|
||||||
|
...(expiry
|
||||||
|
? {
|
||||||
|
[ttl]: { N: Math.floor(expiry.getTime() / 1000).toString() }
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
value: { S: JSON.stringify(value) }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
await dynamo('PutItem', params);
|
||||||
|
},
|
||||||
|
|
||||||
|
async remove(key: string[]) {
|
||||||
|
const { pk: keyPk, sk: keySk } = parseKey(key);
|
||||||
|
const params = {
|
||||||
|
TableName: tableName,
|
||||||
|
Key: {
|
||||||
|
[pk]: { S: keyPk },
|
||||||
|
[sk]: { S: keySk }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
await dynamo('DeleteItem', params);
|
||||||
|
},
|
||||||
|
|
||||||
|
async *scan(prefix: string[]) {
|
||||||
|
const prefixPk = prefix.length >= 2 ? joinKey(prefix.slice(0, 2)) : prefix[0];
|
||||||
|
const prefixSk = prefix.length > 2 ? joinKey(prefix.slice(2)) : '';
|
||||||
|
let lastEvaluatedKey = undefined;
|
||||||
|
const now = Date.now() / 1000;
|
||||||
|
while (true) {
|
||||||
|
const params = {
|
||||||
|
TableName: tableName,
|
||||||
|
ExclusiveStartKey: lastEvaluatedKey,
|
||||||
|
KeyConditionExpression: prefixSk ? `#pk = :pk AND begins_with(#sk, :sk)` : `#pk = :pk`,
|
||||||
|
ExpressionAttributeNames: {
|
||||||
|
'#pk': pk,
|
||||||
|
...(prefixSk && { '#sk': sk })
|
||||||
|
},
|
||||||
|
ExpressionAttributeValues: {
|
||||||
|
':pk': { S: prefixPk },
|
||||||
|
...(prefixSk && { ':sk': { S: prefixSk } })
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await dynamo('Query', params);
|
||||||
|
|
||||||
|
for (const item of result.Items || []) {
|
||||||
|
if (item[ttl] && item[ttl].N < now) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
yield [[item[pk].S, item[sk].S], JSON.parse(item.value.S)];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!result.LastEvaluatedKey) break;
|
||||||
|
lastEvaluatedKey = result.LastEvaluatedKey;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
133
packages/auth/src/storage/memory.ts
Normal file
133
packages/auth/src/storage/memory.ts
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
import { existsSync, readFileSync } from 'node:fs';
|
||||||
|
import { writeFile } from 'node:fs/promises';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configure OpenAuth to use a simple in-memory store.
|
||||||
|
*
|
||||||
|
* :::caution
|
||||||
|
* This is not meant to be used in production.
|
||||||
|
* :::
|
||||||
|
*
|
||||||
|
* This is useful for testing and development. It's not meant to be used in production.
|
||||||
|
*
|
||||||
|
* ```ts
|
||||||
|
* import { MemoryStorage } from "@openauthjs/openauth/storage/memory"
|
||||||
|
*
|
||||||
|
* const storage = MemoryStorage()
|
||||||
|
*
|
||||||
|
* export default issuer({
|
||||||
|
* storage,
|
||||||
|
* // ...
|
||||||
|
* })
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* Optionally, you can persist the store to a file.
|
||||||
|
*
|
||||||
|
* ```ts
|
||||||
|
* MemoryStorage({
|
||||||
|
* persist: "./persist.json"
|
||||||
|
* })
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @packageDocumentation
|
||||||
|
*/
|
||||||
|
import { joinKey, splitKey, StorageAdapter } from './storage.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configure the memory store.
|
||||||
|
*/
|
||||||
|
export interface MemoryStorageOptions {
|
||||||
|
/**
|
||||||
|
* Optionally, backup the store to a file. So it'll be persisted when the issuer restarts.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* {
|
||||||
|
* persist: "./persist.json"
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
persist?: string;
|
||||||
|
}
|
||||||
|
export function MemoryStorage(input?: MemoryStorageOptions): StorageAdapter {
|
||||||
|
const store = [] as [string, { value: Record<string, any>; expiry?: number }][];
|
||||||
|
|
||||||
|
if (input?.persist) {
|
||||||
|
if (existsSync(input.persist)) {
|
||||||
|
const file = readFileSync(input?.persist);
|
||||||
|
store.push(...JSON.parse(file.toString()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
if (!input?.persist) return;
|
||||||
|
const file = JSON.stringify(store);
|
||||||
|
await writeFile(input.persist, file);
|
||||||
|
}
|
||||||
|
|
||||||
|
function search(key: string) {
|
||||||
|
let left = 0;
|
||||||
|
let right = store.length - 1;
|
||||||
|
while (left <= right) {
|
||||||
|
const mid = Math.floor((left + right) / 2);
|
||||||
|
const comparison = key.localeCompare(store[mid][0]);
|
||||||
|
|
||||||
|
if (comparison === 0) {
|
||||||
|
return { found: true, index: mid };
|
||||||
|
} else if (comparison < 0) {
|
||||||
|
right = mid - 1;
|
||||||
|
} else {
|
||||||
|
left = mid + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { found: false, index: left };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
async get(key: string[]) {
|
||||||
|
const match = search(joinKey(key));
|
||||||
|
if (!match.found) return undefined;
|
||||||
|
const entry = store[match.index][1];
|
||||||
|
if (entry.expiry && Date.now() >= entry.expiry) {
|
||||||
|
store.splice(match.index, 1);
|
||||||
|
await save();
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return entry.value;
|
||||||
|
},
|
||||||
|
async set(key: string[], value: any, expiry?: Date) {
|
||||||
|
const joined = joinKey(key);
|
||||||
|
const match = search(joined);
|
||||||
|
// Handle both Date objects and TTL numbers while maintaining Date type in signature
|
||||||
|
const entry = [
|
||||||
|
joined,
|
||||||
|
{
|
||||||
|
value,
|
||||||
|
expiry: expiry ? expiry.getTime() : expiry
|
||||||
|
}
|
||||||
|
] as (typeof store)[number];
|
||||||
|
if (!match.found) {
|
||||||
|
store.splice(match.index, 0, entry);
|
||||||
|
} else {
|
||||||
|
store[match.index] = entry;
|
||||||
|
}
|
||||||
|
await save();
|
||||||
|
},
|
||||||
|
async remove(key: string[]) {
|
||||||
|
const joined = joinKey(key);
|
||||||
|
const match = search(joined);
|
||||||
|
if (match.found) {
|
||||||
|
store.splice(match.index, 1);
|
||||||
|
await save();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async *scan(prefix: string[]) {
|
||||||
|
const now = Date.now();
|
||||||
|
const prefixStr = joinKey(prefix);
|
||||||
|
for (const [key, entry] of store) {
|
||||||
|
if (!key.startsWith(prefixStr)) continue;
|
||||||
|
if (entry.expiry && now >= entry.expiry) continue;
|
||||||
|
yield [splitKey(key), entry.value];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
38
packages/auth/src/storage/storage.ts
Normal file
38
packages/auth/src/storage/storage.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
export interface StorageAdapter {
|
||||||
|
get(key: string[]): Promise<Record<string, any> | undefined>;
|
||||||
|
remove(key: string[]): Promise<void>;
|
||||||
|
set(key: string[], value: any, expiry?: Date): Promise<void>;
|
||||||
|
scan(prefix: string[]): AsyncIterable<[string[], any]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SEPERATOR = String.fromCharCode(0x1f);
|
||||||
|
|
||||||
|
export function joinKey(key: string[]) {
|
||||||
|
return key.join(SEPERATOR);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function splitKey(key: string) {
|
||||||
|
return key.split(SEPERATOR);
|
||||||
|
}
|
||||||
|
|
||||||
|
export namespace Storage {
|
||||||
|
function encode(key: string[]) {
|
||||||
|
return key.map((k) => k.replaceAll(SEPERATOR, ''));
|
||||||
|
}
|
||||||
|
export function get<T>(adapter: StorageAdapter, key: string[]) {
|
||||||
|
return adapter.get(encode(key)) as Promise<T | null>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function set(adapter: StorageAdapter, key: string[], value: any, ttl?: number) {
|
||||||
|
const expiry = ttl ? new Date(Date.now() + ttl * 1000) : undefined;
|
||||||
|
return adapter.set(encode(key), value, expiry);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function remove(adapter: StorageAdapter, key: string[]) {
|
||||||
|
return adapter.remove(encode(key));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function scan<T>(adapter: StorageAdapter, key: string[]): AsyncIterable<[string[], T]> {
|
||||||
|
return adapter.scan(encode(key));
|
||||||
|
}
|
||||||
|
}
|
||||||
129
packages/auth/src/subject.ts
Normal file
129
packages/auth/src/subject.ts
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
/**
|
||||||
|
* Subjects are what the access token generated at the end of the auth flow will map to. Under
|
||||||
|
* the hood, the access token is a JWT that contains this data.
|
||||||
|
*
|
||||||
|
* #### Define subjects
|
||||||
|
*
|
||||||
|
* ```ts title="subjects.ts"
|
||||||
|
* import { object, string } from "valibot"
|
||||||
|
*
|
||||||
|
* const subjects = createSubjects({
|
||||||
|
* user: object({
|
||||||
|
* userID: string()
|
||||||
|
* })
|
||||||
|
* })
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* We are using [valibot](https://github.com/fabian-hiller/valibot) here. You can use any
|
||||||
|
* validation library that's following the
|
||||||
|
* [standard-schema specification](https://github.com/standard-schema/standard-schema).
|
||||||
|
*
|
||||||
|
* :::tip
|
||||||
|
* You typically want to place subjects in its own file so it can be imported by all of your apps.
|
||||||
|
* :::
|
||||||
|
*
|
||||||
|
* You can start with one subject. Later you can add more for different types of users.
|
||||||
|
*
|
||||||
|
* #### Set the subjects
|
||||||
|
*
|
||||||
|
* Then you can pass it to the `issuer`.
|
||||||
|
*
|
||||||
|
* ```ts title="issuer.ts"
|
||||||
|
* import { subjects } from "./subjects"
|
||||||
|
*
|
||||||
|
* const app = issuer({
|
||||||
|
* providers: { ... },
|
||||||
|
* subjects,
|
||||||
|
* // ...
|
||||||
|
* })
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* #### Add the subject payload
|
||||||
|
*
|
||||||
|
* When your user completes the flow, you can add the subject payload in the `success` callback.
|
||||||
|
*
|
||||||
|
* ```ts title="issuer.ts"
|
||||||
|
* const app = issuer({
|
||||||
|
* providers: { ... },
|
||||||
|
* subjects,
|
||||||
|
* async success(ctx, value) {
|
||||||
|
* let userID
|
||||||
|
* if (value.provider === "password") {
|
||||||
|
* console.log(value.email)
|
||||||
|
* userID = ... // lookup user or create them
|
||||||
|
* }
|
||||||
|
* return ctx.subject("user", {
|
||||||
|
* userID
|
||||||
|
* })
|
||||||
|
* },
|
||||||
|
* // ...
|
||||||
|
* })
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* Here we are looking up the userID from our database and adding it to the subject payload.
|
||||||
|
*
|
||||||
|
* :::caution
|
||||||
|
* You should only store properties that won't change for the lifetime of the user.
|
||||||
|
* :::
|
||||||
|
*
|
||||||
|
* Since these will be stored in the access token, you should avoid storing information
|
||||||
|
* that'll change often. For example, if you store the user's username, you'll need to
|
||||||
|
* revoke the access token when the user changes their username.
|
||||||
|
*
|
||||||
|
* #### Decode the subject
|
||||||
|
*
|
||||||
|
* Now when your user logs in, you can use the OpenAuth client to decode the subject. For
|
||||||
|
* example, in our SSR app we can do the following.
|
||||||
|
*
|
||||||
|
* ```ts title="app/page.tsx"
|
||||||
|
* import { subjects } from "../subjects"
|
||||||
|
*
|
||||||
|
* const verified = await client.verify(subjects, cookies.get("access_token")!)
|
||||||
|
* console.log(verified.subject.properties.userID)
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* All this is typesafe based on the shape of the subjects you defined.
|
||||||
|
*
|
||||||
|
* @packageDocumentation
|
||||||
|
*/
|
||||||
|
import type { v1 } from '@standard-schema/spec';
|
||||||
|
|
||||||
|
import { Prettify } from './util.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subject schema is a map of types that are used to define the subjects.
|
||||||
|
*/
|
||||||
|
export type SubjectSchema = Record<string, v1.StandardSchema>;
|
||||||
|
|
||||||
|
/** @internal */
|
||||||
|
export type SubjectPayload<T extends SubjectSchema> = Prettify<
|
||||||
|
{
|
||||||
|
[type in keyof T & string]: {
|
||||||
|
type: type;
|
||||||
|
properties: v1.InferOutput<T[type]>;
|
||||||
|
};
|
||||||
|
}[keyof T & string]
|
||||||
|
>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a subject schema.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* const subjects = createSubjects({
|
||||||
|
* user: object({
|
||||||
|
* userID: string()
|
||||||
|
* }),
|
||||||
|
* admin: object({
|
||||||
|
* workspaceID: string()
|
||||||
|
* })
|
||||||
|
* })
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* This is using [valibot](https://github.com/fabian-hiller/valibot) to define the shape of the
|
||||||
|
* subjects. You can use any validation library that's following the
|
||||||
|
* [standard-schema specification](https://github.com/standard-schema/standard-schema).
|
||||||
|
*/
|
||||||
|
export function createSubjects<Schema extends SubjectSchema = {}>(types: Schema): Schema {
|
||||||
|
return { ...types };
|
||||||
|
}
|
||||||
104
packages/auth/src/ui/base.tsx
Normal file
104
packages/auth/src/ui/base.tsx
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
import { PropsWithChildren } from 'hono/jsx';
|
||||||
|
|
||||||
|
import { getTheme } from './theme.js';
|
||||||
|
|
||||||
|
import css from './css.js';
|
||||||
|
|
||||||
|
export function Layout(
|
||||||
|
props: PropsWithChildren<{
|
||||||
|
size?: 'small';
|
||||||
|
}>
|
||||||
|
) {
|
||||||
|
const theme = getTheme();
|
||||||
|
function get(key: 'primary' | 'background' | 'logo', mode: 'light' | 'dark') {
|
||||||
|
if (!theme) return;
|
||||||
|
if (!theme[key]) return;
|
||||||
|
if (typeof theme[key] === 'string') return theme[key];
|
||||||
|
|
||||||
|
return theme[key][mode] as string | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const radius = (() => {
|
||||||
|
if (theme?.radius === 'none') return '0';
|
||||||
|
if (theme?.radius === 'sm') return '1';
|
||||||
|
if (theme?.radius === 'md') return '1.25';
|
||||||
|
if (theme?.radius === 'lg') return '1.5';
|
||||||
|
if (theme?.radius === 'full') return '1000000000001';
|
||||||
|
return '1';
|
||||||
|
})();
|
||||||
|
|
||||||
|
const hasLogo = get('logo', 'light') && get('logo', 'dark');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<html
|
||||||
|
style={{
|
||||||
|
'--color-background-light': get('background', 'light'),
|
||||||
|
'--color-background-dark': get('background', 'dark'),
|
||||||
|
'--color-primary-light': get('primary', 'light'),
|
||||||
|
'--color-primary-dark': get('primary', 'dark'),
|
||||||
|
'--font-family': theme?.font?.family,
|
||||||
|
'--font-scale': theme?.font?.scale,
|
||||||
|
'--border-radius': radius
|
||||||
|
}}>
|
||||||
|
<head>
|
||||||
|
<title>{theme?.title || 'OpenAuthJS'}</title>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
{theme?.favicon ? (
|
||||||
|
<link rel="icon" href={theme?.favicon} />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<link rel="icon" href="https://openauth.js.org/favicon.ico" sizes="48x48" />
|
||||||
|
<link
|
||||||
|
rel="icon"
|
||||||
|
href="https://openauth.js.org/favicon.svg"
|
||||||
|
media="(prefers-color-scheme: light)"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="icon"
|
||||||
|
href="https://openauth.js.org/favicon-dark.svg"
|
||||||
|
media="(prefers-color-scheme: dark)"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="shortcut icon"
|
||||||
|
href="https://openauth.js.org/favicon.svg"
|
||||||
|
type="image/svg+xml"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<style dangerouslySetInnerHTML={{ __html: css }} />
|
||||||
|
{theme?.css && <style dangerouslySetInnerHTML={{ __html: theme.css }} />}
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div data-component="root">
|
||||||
|
<div data-component="center" data-size={props.size}>
|
||||||
|
{hasLogo ? (
|
||||||
|
<>
|
||||||
|
<img data-component="logo" src={get('logo', 'light')} data-mode="light" />
|
||||||
|
<img data-component="logo" src={get('logo', 'dark')} data-mode="dark" />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
ICON_OPENAUTH
|
||||||
|
)}
|
||||||
|
{props.children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ICON_OPENAUTH = (
|
||||||
|
<svg
|
||||||
|
data-component="logo-default"
|
||||||
|
width="51"
|
||||||
|
height="51"
|
||||||
|
viewBox="0 0 51 51"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path
|
||||||
|
d="M0 50.2303V0.12854H50.1017V50.2303H0ZM3.08002 11.8326H11.7041V3.20856H3.08002V11.8326ZM14.8526 11.8326H23.4766V3.20856H14.8526V11.8326ZM26.5566 11.8326H35.1807V3.20856H26.5566V11.8326ZM38.3292 11.8326H47.0217V3.20856H38.3292V11.8326ZM3.08002 23.6052H11.7041V14.9811H3.08002V23.6052ZM14.8526 23.6052H23.4766V14.9811H14.8526V23.6052ZM26.5566 23.6052H35.1807V14.9811H26.5566V23.6052ZM38.3292 23.6052H47.0217V14.9811H38.3292V23.6052ZM3.08002 35.3092H11.7041V26.6852H3.08002V35.3092ZM14.8526 35.3092H23.4766V26.6852H14.8526V35.3092ZM26.5566 35.3092H35.1807V26.6852H26.5566V35.3092ZM38.3292 35.3092H47.0217V26.6852H38.3292V35.3092ZM3.08002 47.1502H11.7041V38.3893H3.08002V47.1502ZM14.8526 47.1502H23.4766V38.3893H14.8526V47.1502ZM26.5566 47.1502H35.1807V38.3893H26.5566V47.1502ZM38.3292 47.1502H47.0217V38.3893H38.3292V47.1502Z"
|
||||||
|
fill="currentColor"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
198
packages/auth/src/ui/code.tsx
Normal file
198
packages/auth/src/ui/code.tsx
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
/**
|
||||||
|
* Configure the UI that's used by the Code provider.
|
||||||
|
*
|
||||||
|
* ```ts {1,7-12}
|
||||||
|
* import { CodeUI } from "@openauthjs/openauth/ui/code"
|
||||||
|
* import { CodeProvider } from "@openauthjs/openauth/provider/code"
|
||||||
|
*
|
||||||
|
* export default issuer({
|
||||||
|
* providers: {
|
||||||
|
* code: CodeAdapter(
|
||||||
|
* CodeUI({
|
||||||
|
* copy: {
|
||||||
|
* code_info: "We'll send a pin code to your email"
|
||||||
|
* },
|
||||||
|
* sendCode: (claims, code) => console.log(claims.email, code)
|
||||||
|
* })
|
||||||
|
* )
|
||||||
|
* },
|
||||||
|
* // ...
|
||||||
|
* })
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @packageDocumentation
|
||||||
|
*/
|
||||||
|
/** @jsxImportSource hono/jsx */
|
||||||
|
|
||||||
|
import { UnknownStateError } from '../error.js';
|
||||||
|
import { CodeProviderOptions } from '../provider/code.js';
|
||||||
|
import { Layout } from './base.js';
|
||||||
|
import { FormAlert } from './form.js';
|
||||||
|
|
||||||
|
const DEFAULT_COPY = {
|
||||||
|
/**
|
||||||
|
* Copy for the email input.
|
||||||
|
*/
|
||||||
|
email_placeholder: 'Email',
|
||||||
|
/**
|
||||||
|
* Error message when the email is invalid.
|
||||||
|
*/
|
||||||
|
email_invalid: 'Email address is not valid',
|
||||||
|
/**
|
||||||
|
* Copy for the continue button.
|
||||||
|
*/
|
||||||
|
button_continue: 'Continue',
|
||||||
|
/**
|
||||||
|
* Copy informing that the pin code will be emailed.
|
||||||
|
*/
|
||||||
|
code_info: "We'll send a pin code to your email.",
|
||||||
|
/**
|
||||||
|
* Copy for the pin code input.
|
||||||
|
*/
|
||||||
|
code_placeholder: 'Code',
|
||||||
|
/**
|
||||||
|
* Error message when the code is invalid.
|
||||||
|
*/
|
||||||
|
code_invalid: 'Invalid code',
|
||||||
|
/**
|
||||||
|
* Copy for when the code was sent.
|
||||||
|
*/
|
||||||
|
code_sent: 'Code sent to ',
|
||||||
|
/**
|
||||||
|
* Copy for when the code was resent.
|
||||||
|
*/
|
||||||
|
code_resent: 'Code resent to ',
|
||||||
|
/**
|
||||||
|
* Copy for the link to resend the code.
|
||||||
|
*/
|
||||||
|
code_didnt_get: "Didn't get code?",
|
||||||
|
/**
|
||||||
|
* Copy for the resend button.
|
||||||
|
*/
|
||||||
|
code_resend: 'Resend'
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CodeUICopy = typeof DEFAULT_COPY;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configure the password UI.
|
||||||
|
*/
|
||||||
|
export interface CodeUIOptions {
|
||||||
|
/**
|
||||||
|
* Callback to send the pin code to the user.
|
||||||
|
*
|
||||||
|
* The `claims` object contains the email or phone number of the user. You can send the code
|
||||||
|
* using this.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* async (claims, code) => {
|
||||||
|
* // Send the code via the claim
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
sendCode: (claims: Record<string, string>, code: string) => Promise<void>;
|
||||||
|
/**
|
||||||
|
* Custom copy for the UI.
|
||||||
|
*/
|
||||||
|
copy?: Partial<CodeUICopy>;
|
||||||
|
/**
|
||||||
|
* The mode to use for the input.
|
||||||
|
* @default "email"
|
||||||
|
*/
|
||||||
|
mode?: 'email' | 'phone';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a UI for the Code provider flow.
|
||||||
|
* @param props - Configure the UI.
|
||||||
|
*/
|
||||||
|
export function CodeUI(props: CodeUIOptions): CodeProviderOptions {
|
||||||
|
const copy = {
|
||||||
|
...DEFAULT_COPY,
|
||||||
|
...props.copy
|
||||||
|
};
|
||||||
|
|
||||||
|
const mode = props.mode ?? 'email';
|
||||||
|
|
||||||
|
return {
|
||||||
|
sendCode: props.sendCode,
|
||||||
|
length: 6,
|
||||||
|
request: async (_req, state, _form, error): Promise<Response> => {
|
||||||
|
if (state.type === 'start') {
|
||||||
|
const jsx = (
|
||||||
|
<Layout>
|
||||||
|
<form data-component="form" method="post">
|
||||||
|
{error?.type === 'invalid_claim' && <FormAlert message={copy.email_invalid} />}
|
||||||
|
<input type="hidden" name="action" value="request" />
|
||||||
|
<input
|
||||||
|
data-component="input"
|
||||||
|
autofocus
|
||||||
|
type={mode === 'email' ? 'email' : 'tel'}
|
||||||
|
name={mode === 'email' ? 'email' : 'phone'}
|
||||||
|
inputmode={mode === 'email' ? 'email' : 'numeric'}
|
||||||
|
required
|
||||||
|
placeholder={copy.email_placeholder}
|
||||||
|
/>
|
||||||
|
<button data-component="button">{copy.button_continue}</button>
|
||||||
|
</form>
|
||||||
|
<p data-component="form-footer">{copy.code_info}</p>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
return new Response(jsx.toString(), {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'text/html'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.type === 'code') {
|
||||||
|
const jsx = (
|
||||||
|
<Layout>
|
||||||
|
<form data-component="form" class="form" method="post">
|
||||||
|
{error?.type === 'invalid_code' && <FormAlert message={copy.code_invalid} />}
|
||||||
|
{state.type === 'code' && (
|
||||||
|
<FormAlert
|
||||||
|
message={(state.resend ? copy.code_resent : copy.code_sent) + state.claims.email}
|
||||||
|
color="success"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<input type="hidden" name="action" value="verify" />
|
||||||
|
<input
|
||||||
|
data-component="input"
|
||||||
|
autofocus
|
||||||
|
minLength={6}
|
||||||
|
maxLength={6}
|
||||||
|
type="text"
|
||||||
|
name="code"
|
||||||
|
required
|
||||||
|
inputmode="numeric"
|
||||||
|
autocomplete="one-time-code"
|
||||||
|
placeholder={copy.code_placeholder}
|
||||||
|
/>
|
||||||
|
<button data-component="button">{copy.button_continue}</button>
|
||||||
|
</form>
|
||||||
|
<form method="post">
|
||||||
|
{Object.entries(state.claims).map(([key, value]) => (
|
||||||
|
<input key={key} type="hidden" name={key} value={value} className="hidden" />
|
||||||
|
))}
|
||||||
|
<input type="hidden" name="action" value="request" />
|
||||||
|
<div data-component="form-footer">
|
||||||
|
<span>
|
||||||
|
{copy.code_didnt_get} <button data-component="link">{copy.code_resend}</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
return new Response(jsx.toString(), {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'text/html'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new UnknownStateError();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
247
packages/auth/src/ui/css.ts
Normal file
247
packages/auth/src/ui/css.ts
Normal file
@@ -0,0 +1,247 @@
|
|||||||
|
export default `:root {
|
||||||
|
--color-background-dark: #0e0e11;
|
||||||
|
--color-background-light: #ffffff;
|
||||||
|
--color-primary-dark: #6772e5;
|
||||||
|
--color-primary-light: #6772e5;
|
||||||
|
|
||||||
|
--color-background-success-dark: oklch(0.3 0.04 172);
|
||||||
|
--color-background-success-light: oklch(from var(--color-background-success-dark) 0.83 c h);
|
||||||
|
--color-success-dark: oklch(from var(--color-background-success-dark) 0.92 c h);
|
||||||
|
--color-success-light: oklch(from var(--color-background-success-dark) 0.25 c h);
|
||||||
|
|
||||||
|
--color-background-error-dark: oklch(0.32 0.07 15);
|
||||||
|
--color-background-error-light: oklch(from var(--color-background-error-dark) 0.92 c h);
|
||||||
|
--color-error-dark: oklch(from var(--color-background-error-dark) 0.92 c h);
|
||||||
|
--color-error-light: oklch(from var(--color-background-error-dark) 0.25 c h);
|
||||||
|
|
||||||
|
--border-radius: 0;
|
||||||
|
|
||||||
|
--color-background: var(--color-background-dark);
|
||||||
|
--color-primary: var(--color-primary-dark);
|
||||||
|
|
||||||
|
--color-background-success: var(--color-background-success-dark);
|
||||||
|
--color-success: var(--color-success-dark);
|
||||||
|
--color-background-error: var(--color-background-error-dark);
|
||||||
|
--color-error: var(--color-error-dark);
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: light) {
|
||||||
|
--color-background: var(--color-background-light);
|
||||||
|
--color-primary: var(--color-primary-light);
|
||||||
|
|
||||||
|
--color-background-success: var(--color-background-success-light);
|
||||||
|
--color-success: var(--color-success-light);
|
||||||
|
--color-background-error: var(--color-background-error-light);
|
||||||
|
--color-error: var(--color-error-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
--color-high: oklch(from var(--color-background) clamp(0, calc((l - 0.714) * -1000), 1) 0 0);
|
||||||
|
--color-low: oklch(from var(--color-background) clamp(0, calc((l - 0.714) * 1000), 1) 0 0);
|
||||||
|
--lightness-high: color-mix(in oklch, var(--color-high) 0%, oklch(var(--color-high) 0 0));
|
||||||
|
--lightness-low: color-mix(in oklch, var(--color-low) 0%, oklch(var(--color-low) 0 0));
|
||||||
|
--font-family:
|
||||||
|
ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol',
|
||||||
|
'Noto Color Emoji';
|
||||||
|
--font-scale: 1;
|
||||||
|
|
||||||
|
--font-size-xs: calc(0.75rem * var(--font-scale));
|
||||||
|
--font-size-sm: calc(0.875rem * var(--font-scale));
|
||||||
|
--font-size-md: calc(1rem * var(--font-scale));
|
||||||
|
--font-size-lg: calc(1.125rem * var(--font-scale));
|
||||||
|
--font-size-xl: calc(1.25rem * var(--font-scale));
|
||||||
|
--font-size-2xl: calc(1.5rem * var(--font-scale));
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-component='root'] {
|
||||||
|
font-family: var(--font-family);
|
||||||
|
background-color: var(--color-background);
|
||||||
|
padding: 1rem;
|
||||||
|
color: white;
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-direction: column;
|
||||||
|
user-select: none;
|
||||||
|
color: var(--color-high);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-component='center'] {
|
||||||
|
width: 380px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.5rem;
|
||||||
|
|
||||||
|
&[data-size='small'] {
|
||||||
|
width: 300px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-component='link'] {
|
||||||
|
text-decoration: underline;
|
||||||
|
text-underline-offset: 0.125rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-component='label'] {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
flex-direction: column;
|
||||||
|
font-size: var(--font-size-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-component='logo'] {
|
||||||
|
margin: 0 auto;
|
||||||
|
height: 2.5rem;
|
||||||
|
width: auto;
|
||||||
|
display: none;
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: light) {
|
||||||
|
&[data-mode='light'] {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
&[data-mode='dark'] {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-component='logo-default'] {
|
||||||
|
margin: 0 auto;
|
||||||
|
height: 2.5rem;
|
||||||
|
width: auto;
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: light) {
|
||||||
|
color: var(--color-high);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
color: var(--color-high);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-component='input'] {
|
||||||
|
width: 100%;
|
||||||
|
height: 2.5rem;
|
||||||
|
padding: 0 1rem;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
--background: oklch(
|
||||||
|
from var(--color-background) calc(l + (-0.06 * clamp(0, calc((l - 0.714) * 1000), 1) + 0.03)) c
|
||||||
|
h
|
||||||
|
);
|
||||||
|
background: var(--background);
|
||||||
|
border-color: oklch(
|
||||||
|
from var(--color-background)
|
||||||
|
calc(clamp(0.22, l + (-0.12 * clamp(0, calc((l - 0.714) * 1000), 1) + 0.06), 0.88)) c h
|
||||||
|
);
|
||||||
|
border-radius: calc(var(--border-radius) * 0.25rem);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
outline: none;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
border-color: oklch(
|
||||||
|
from var(--color-background)
|
||||||
|
calc(clamp(0.3, l + (-0.2 * clamp(0, calc((l - 0.714) * 1000), 1) + 0.1), 0.7)) c h
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:user-invalid:not(:focus) {
|
||||||
|
border-color: oklch(0.4 0.09 7.91);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-component='button'] {
|
||||||
|
height: 2.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
border: 0;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
border-radius: calc(var(--border-radius) * 0.25rem);
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: var(--color-primary);
|
||||||
|
color: oklch(from var(--color-primary) clamp(0, calc((l - 0.714) * -1000), 1) 0 0);
|
||||||
|
|
||||||
|
&[data-color='ghost'] {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-high);
|
||||||
|
border: 1px solid
|
||||||
|
oklch(
|
||||||
|
from var(--color-background)
|
||||||
|
calc(clamp(0.22, l + (-0.12 * clamp(0, calc((l - 0.714) * 1000), 1) + 0.06), 0.88)) c h
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-slot='icon'] {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-component='form'] {
|
||||||
|
max-width: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-component='form-alert'] {
|
||||||
|
height: 2.5rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 1rem;
|
||||||
|
border-radius: calc(var(--border-radius) * 0.25rem);
|
||||||
|
background: var(--color-background-error);
|
||||||
|
color: var(--color-error);
|
||||||
|
text-align: left;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
gap: 0.5rem;
|
||||||
|
|
||||||
|
&[data-color='success'] {
|
||||||
|
background: var(--color-background-success);
|
||||||
|
color: var(--color-success);
|
||||||
|
|
||||||
|
[data-slot='icon-success'] {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
[data-slot='icon-danger'] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:has([data-slot='message']:empty) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-slot='icon-success'],
|
||||||
|
[data-slot='icon-danger'] {
|
||||||
|
width: 1rem;
|
||||||
|
height: 1rem;
|
||||||
|
}
|
||||||
|
[data-slot='icon-success'] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-component='form-footer'] {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
&:has(> :nth-child(2)) {
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
}`;
|
||||||
35
packages/auth/src/ui/form.tsx
Normal file
35
packages/auth/src/ui/form.tsx
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
/** @jsxImportSource hono/jsx */
|
||||||
|
|
||||||
|
export function FormAlert(props: { message?: string; color?: 'danger' | 'success' }) {
|
||||||
|
return (
|
||||||
|
<div data-component="form-alert" data-color={props.color}>
|
||||||
|
<svg
|
||||||
|
data-slot="icon-success"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke-width="1.5"
|
||||||
|
stroke="currentColor">
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
d="M9 12.75 11.25 15 15 9.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<svg
|
||||||
|
data-slot="icon-danger"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke-width="1.5"
|
||||||
|
stroke="currentColor">
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
d="M12 9v3.75m9-.75a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9 3.75h.008v.008H12v-.008Z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span data-slot="message">{props.message}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
86
packages/auth/src/ui/icon.tsx
Normal file
86
packages/auth/src/ui/icon.tsx
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
/** @jsxImportSource hono/jsx */
|
||||||
|
|
||||||
|
export const ICON_GITHUB = (
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 256 250"
|
||||||
|
width="256"
|
||||||
|
height="250"
|
||||||
|
fill="currentColor"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
preserveAspectRatio="xMidYMid">
|
||||||
|
<path d="M128.001 0C57.317 0 0 57.307 0 128.001c0 56.554 36.676 104.535 87.535 121.46 6.397 1.185 8.746-2.777 8.746-6.158 0-3.052-.12-13.135-.174-23.83-35.61 7.742-43.124-15.103-43.124-15.103-5.823-14.795-14.213-18.73-14.213-18.73-11.613-7.944.876-7.78.876-7.78 12.853.902 19.621 13.19 19.621 13.19 11.417 19.568 29.945 13.911 37.249 10.64 1.149-8.272 4.466-13.92 8.127-17.116-28.431-3.236-58.318-14.212-58.318-63.258 0-13.975 5-25.394 13.188-34.358-1.329-3.224-5.71-16.242 1.24-33.874 0 0 10.749-3.44 35.21 13.121 10.21-2.836 21.16-4.258 32.038-4.307 10.878.049 21.837 1.47 32.066 4.307 24.431-16.56 35.165-13.12 35.165-13.12 6.967 17.63 2.584 30.65 1.255 33.873 8.207 8.964 13.173 20.383 13.173 34.358 0 49.163-29.944 59.988-58.447 63.157 4.591 3.972 8.682 11.762 8.682 23.704 0 17.126-.148 30.91-.148 35.126 0 3.407 2.304 7.398 8.792 6.14C219.37 232.5 256 184.537 256 128.002 256 57.307 198.691 0 128.001 0Zm-80.06 182.34c-.282.636-1.283.827-2.194.39-.929-.417-1.45-1.284-1.15-1.922.276-.655 1.279-.838 2.205-.399.93.418 1.46 1.293 1.139 1.931Zm6.296 5.618c-.61.566-1.804.303-2.614-.591-.837-.892-.994-2.086-.375-2.66.63-.566 1.787-.301 2.626.591.838.903 1 2.088.363 2.66Zm4.32 7.188c-.785.545-2.067.034-2.86-1.104-.784-1.138-.784-2.503.017-3.05.795-.547 2.058-.055 2.861 1.075.782 1.157.782 2.522-.019 3.08Zm7.304 8.325c-.701.774-2.196.566-3.29-.49-1.119-1.032-1.43-2.496-.726-3.27.71-.776 2.213-.558 3.315.49 1.11 1.03 1.45 2.505.701 3.27Zm9.442 2.81c-.31 1.003-1.75 1.459-3.199 1.033-1.448-.439-2.395-1.613-2.103-2.626.301-1.01 1.747-1.484 3.207-1.028 1.446.436 2.396 1.602 2.095 2.622Zm10.744 1.193c.036 1.055-1.193 1.93-2.715 1.95-1.53.034-2.769-.82-2.786-1.86 0-1.065 1.202-1.932 2.733-1.958 1.522-.03 2.768.818 2.768 1.868Zm10.555-.405c.182 1.03-.875 2.088-2.387 2.37-1.485.271-2.861-.365-3.05-1.386-.184-1.056.893-2.114 2.376-2.387 1.514-.263 2.868.356 3.061 1.403Z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const ICON_GOOGLE = (
|
||||||
|
<svg
|
||||||
|
width="256"
|
||||||
|
height="262"
|
||||||
|
viewBox="0 0 256 262"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
preserveAspectRatio="xMidYMid">
|
||||||
|
<path
|
||||||
|
d="M255.878 133.451c0-10.734-.871-18.567-2.756-26.69H130.55v48.448h71.947c-1.45 12.04-9.283 30.172-26.69 42.356l-.244 1.622 38.755 30.023 2.685.268c24.659-22.774 38.875-56.282 38.875-96.027"
|
||||||
|
fill="#4285F4"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M130.55 261.1c35.248 0 64.839-11.605 86.453-31.622l-41.196-31.913c-11.024 7.688-25.82 13.055-45.257 13.055-34.523 0-63.824-22.773-74.269-54.25l-1.531.13-40.298 31.187-.527 1.465C35.393 231.798 79.49 261.1 130.55 261.1"
|
||||||
|
fill="#34A853"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M56.281 156.37c-2.756-8.123-4.351-16.827-4.351-25.82 0-8.994 1.595-17.697 4.206-25.82l-.073-1.73L15.26 71.312l-1.335.635C5.077 89.644 0 109.517 0 130.55s5.077 40.905 13.925 58.602l42.356-32.782"
|
||||||
|
fill="#FBBC05"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M130.55 50.479c24.514 0 41.05 10.589 50.479 19.438l36.844-35.974C195.245 12.91 165.798 0 130.55 0 79.49 0 35.393 29.301 13.925 71.947l42.211 32.783c10.59-31.477 39.891-54.251 74.414-54.251"
|
||||||
|
fill="#EB4335"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const ICON_EMAIL = (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke-width="1.5"
|
||||||
|
stroke="currentColor"
|
||||||
|
class="size-6">
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
d="M21.75 6.75v10.5a2.25 2.25 0 0 1-2.25 2.25h-15a2.25 2.25 0 0 1-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0 0 19.5 4.5h-15a2.25 2.25 0 0 0-2.25 2.25m19.5 0v.243a2.25 2.25 0 0 1-1.07 1.916l-7.5 4.615a2.25 2.25 0 0 1-2.36 0L3.32 8.91a2.25 2.25 0 0 1-1.07-1.916V6.75"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const ICON_SLACK = (
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g>
|
||||||
|
<path
|
||||||
|
fill-rule="evenodd"
|
||||||
|
clip-rule="evenodd"
|
||||||
|
d="M8.79948 0C7.47279 0.000978593 6.39909 1.07547 6.40007 2.39951C6.39909 3.72355 7.47377 4.79804 8.80046 4.79902H11.2009V2.40049C11.2018 1.07645 10.1271 0.00195719 8.79948 0ZM8.79948 6.4H2.40039C1.07371 6.40098 -0.000977873 7.47547 2.67973e-06 8.79951C-0.00195842 10.1235 1.07273 11.198 2.39941 11.2H8.79948C10.1262 11.199 11.2009 10.1245 11.1999 8.80049C11.2009 7.47547 10.1262 6.40098 8.79948 6.4Z"
|
||||||
|
fill="currentColor"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill-rule="evenodd"
|
||||||
|
clip-rule="evenodd"
|
||||||
|
d="M24.0007 8.79951C24.0016 7.47547 22.9269 6.40098 21.6003 6.4C20.2736 6.40098 19.1989 7.47547 19.1999 8.79951V11.2H21.6003C22.9269 11.199 24.0016 10.1245 24.0007 8.79951ZM17.6006 8.79951V2.39951C17.6016 1.07645 16.5279 0.00195719 15.2012 0C13.8745 0.000978593 12.7998 1.07547 12.8008 2.39951V8.79951C12.7988 10.1235 13.8735 11.198 15.2002 11.2C16.5269 11.199 17.6016 10.1245 17.6006 8.79951Z"
|
||||||
|
fill="currentColor"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill-rule="evenodd"
|
||||||
|
clip-rule="evenodd"
|
||||||
|
d="M15.1992 23.9998C16.5259 23.9988 17.6006 22.9243 17.5996 21.6003C17.6006 20.2763 16.5259 19.2018 15.1992 19.2008H12.7988V21.6003C12.7978 22.9234 13.8725 23.9978 15.1992 23.9998ZM15.1992 17.5988H21.5993C22.926 17.5978 24.0007 16.5234 23.9997 15.1993C24.0016 13.8753 22.927 12.8008 21.6003 12.7988H15.2002C13.8735 12.7998 12.7988 13.8743 12.7998 15.1983C12.7988 16.5234 13.8725 17.5978 15.1992 17.5988Z"
|
||||||
|
fill="currentColor"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill-rule="evenodd"
|
||||||
|
clip-rule="evenodd"
|
||||||
|
d="M0 15.1993C-0.000979882 16.5234 1.07371 17.5978 2.40039 17.5988C3.72708 17.5978 4.80177 16.5234 4.80079 15.1993V12.7998H2.40039C1.07371 12.8008 -0.000979882 13.8753 0 15.1993ZM6.40007 15.1993V21.5993C6.3981 22.9234 7.47279 23.9978 8.79948 23.9998C10.1262 23.9988 11.2009 22.9243 11.1999 21.6003V15.2013C11.2018 13.8772 10.1271 12.8027 8.80046 12.8008C7.47279 12.8008 6.39909 13.8753 6.40007 15.1993Z"
|
||||||
|
fill="currentColor"
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
390
packages/auth/src/ui/password.tsx
Normal file
390
packages/auth/src/ui/password.tsx
Normal file
@@ -0,0 +1,390 @@
|
|||||||
|
/**
|
||||||
|
* Configure the UI that's used by the Password provider.
|
||||||
|
*
|
||||||
|
* ```ts {1,7-12}
|
||||||
|
* import { PasswordUI } from "@openauthjs/openauth/ui/password"
|
||||||
|
* import { PasswordProvider } from "@openauthjs/openauth/provider/password"
|
||||||
|
*
|
||||||
|
* export default issuer({
|
||||||
|
* providers: {
|
||||||
|
* password: PasswordAdapter(
|
||||||
|
* PasswordUI({
|
||||||
|
* copy: {
|
||||||
|
* error_email_taken: "This email is already taken."
|
||||||
|
* },
|
||||||
|
* sendCode: (email, code) => console.log(email, code)
|
||||||
|
* })
|
||||||
|
* )
|
||||||
|
* },
|
||||||
|
* // ...
|
||||||
|
* })
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @packageDocumentation
|
||||||
|
*/
|
||||||
|
/** @jsxImportSource hono/jsx */
|
||||||
|
|
||||||
|
import {
|
||||||
|
PasswordChangeError,
|
||||||
|
PasswordConfig,
|
||||||
|
PasswordLoginError,
|
||||||
|
PasswordRegisterError
|
||||||
|
} from '../provider/password.js';
|
||||||
|
import { Layout } from './base.js';
|
||||||
|
import './form.js';
|
||||||
|
import { FormAlert } from './form.js';
|
||||||
|
|
||||||
|
const DEFAULT_COPY = {
|
||||||
|
/**
|
||||||
|
* Error message when email is already taken.
|
||||||
|
*/
|
||||||
|
error_email_taken: 'There is already an account with this email.',
|
||||||
|
/**
|
||||||
|
* Error message when the confirmation code is incorrect.
|
||||||
|
*/
|
||||||
|
error_invalid_code: 'Code is incorrect.',
|
||||||
|
/**
|
||||||
|
* Error message when the email is invalid.
|
||||||
|
*/
|
||||||
|
error_invalid_email: 'Email is not valid.',
|
||||||
|
/**
|
||||||
|
* Error message when the password is incorrect.
|
||||||
|
*/
|
||||||
|
error_invalid_password: 'Password is incorrect.',
|
||||||
|
/**
|
||||||
|
* Error message when the passwords do not match.
|
||||||
|
*/
|
||||||
|
error_password_mismatch: 'Passwords do not match.',
|
||||||
|
/**
|
||||||
|
* Error message when the user enters a password that fails validation.
|
||||||
|
*/
|
||||||
|
error_validation_error: 'Password does not meet requirements.',
|
||||||
|
/**
|
||||||
|
* Title of the register page.
|
||||||
|
*/
|
||||||
|
register_title: 'Welcome to the app',
|
||||||
|
/**
|
||||||
|
* Description of the register page.
|
||||||
|
*/
|
||||||
|
register_description: 'Sign in with your email',
|
||||||
|
/**
|
||||||
|
* Title of the login page.
|
||||||
|
*/
|
||||||
|
login_title: 'Welcome to the app',
|
||||||
|
/**
|
||||||
|
* Description of the login page.
|
||||||
|
*/
|
||||||
|
login_description: 'Sign in with your email',
|
||||||
|
/**
|
||||||
|
* Copy for the register button.
|
||||||
|
*/
|
||||||
|
register: 'Register',
|
||||||
|
/**
|
||||||
|
* Copy for the register link.
|
||||||
|
*/
|
||||||
|
register_prompt: "Don't have an account?",
|
||||||
|
/**
|
||||||
|
* Copy for the login link.
|
||||||
|
*/
|
||||||
|
login_prompt: 'Already have an account?',
|
||||||
|
/**
|
||||||
|
* Copy for the login button.
|
||||||
|
*/
|
||||||
|
login: 'Login',
|
||||||
|
/**
|
||||||
|
* Copy for the forgot password link.
|
||||||
|
*/
|
||||||
|
change_prompt: 'Forgot password?',
|
||||||
|
/**
|
||||||
|
* Copy for the resend code button.
|
||||||
|
*/
|
||||||
|
code_resend: 'Resend code',
|
||||||
|
/**
|
||||||
|
* Copy for the "Back to" link.
|
||||||
|
*/
|
||||||
|
code_return: 'Back to',
|
||||||
|
/**
|
||||||
|
* Copy for the logo.
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
logo: 'A',
|
||||||
|
/**
|
||||||
|
* Copy for the email input.
|
||||||
|
*/
|
||||||
|
input_email: 'Email',
|
||||||
|
/**
|
||||||
|
* Copy for the password input.
|
||||||
|
*/
|
||||||
|
input_password: 'Password',
|
||||||
|
/**
|
||||||
|
* Copy for the code input.
|
||||||
|
*/
|
||||||
|
input_code: 'Code',
|
||||||
|
/**
|
||||||
|
* Copy for the repeat password input.
|
||||||
|
*/
|
||||||
|
input_repeat: 'Repeat password',
|
||||||
|
/**
|
||||||
|
* Copy for the continue button.
|
||||||
|
*/
|
||||||
|
button_continue: 'Continue'
|
||||||
|
} satisfies {
|
||||||
|
[key in `error_${
|
||||||
|
| PasswordLoginError['type']
|
||||||
|
| PasswordRegisterError['type']
|
||||||
|
| PasswordChangeError['type']}`]: string;
|
||||||
|
} & Record<string, string>;
|
||||||
|
|
||||||
|
type PasswordUICopy = typeof DEFAULT_COPY;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configure the password UI.
|
||||||
|
*/
|
||||||
|
export interface PasswordUIOptions extends Pick<PasswordConfig, 'sendCode' | 'validatePassword'> {
|
||||||
|
/**
|
||||||
|
* Custom copy for the UI.
|
||||||
|
*/
|
||||||
|
copy?: Partial<PasswordUICopy>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a UI for the Password provider flow.
|
||||||
|
* @param input - Configure the UI.
|
||||||
|
*/
|
||||||
|
export function PasswordUI(input: PasswordUIOptions): PasswordConfig {
|
||||||
|
const copy = {
|
||||||
|
...DEFAULT_COPY,
|
||||||
|
...input.copy
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
validatePassword: input.validatePassword,
|
||||||
|
sendCode: input.sendCode,
|
||||||
|
login: async (_req, form, error): Promise<Response> => {
|
||||||
|
const jsx = (
|
||||||
|
<Layout>
|
||||||
|
<form data-component="form" method="post">
|
||||||
|
<FormAlert message={error?.type && copy?.[`error_${error.type}`]} />
|
||||||
|
<input
|
||||||
|
data-component="input"
|
||||||
|
type="email"
|
||||||
|
name="email"
|
||||||
|
required
|
||||||
|
placeholder={copy.input_email}
|
||||||
|
autofocus={!error}
|
||||||
|
value={form?.get('email')?.toString()}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
data-component="input"
|
||||||
|
autofocus={error?.type === 'invalid_password'}
|
||||||
|
required
|
||||||
|
type="password"
|
||||||
|
name="password"
|
||||||
|
placeholder={copy.input_password}
|
||||||
|
autoComplete="current-password"
|
||||||
|
/>
|
||||||
|
<button data-component="button">{copy.button_continue}</button>
|
||||||
|
<div data-component="form-footer">
|
||||||
|
<span>
|
||||||
|
{copy.register_prompt}{' '}
|
||||||
|
<a data-component="link" href="register">
|
||||||
|
{copy.register}
|
||||||
|
</a>
|
||||||
|
</span>
|
||||||
|
<a data-component="link" href="change">
|
||||||
|
{copy.change_prompt}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
return new Response(jsx.toString(), {
|
||||||
|
status: error ? 401 : 200,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'text/html'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
register: async (_req, state, form, error): Promise<Response> => {
|
||||||
|
const emailError = ['invalid_email', 'email_taken'].includes(error?.type || '');
|
||||||
|
const passwordError = ['invalid_password', 'password_mismatch', 'validation_error'].includes(
|
||||||
|
error?.type || ''
|
||||||
|
);
|
||||||
|
const jsx = (
|
||||||
|
<Layout>
|
||||||
|
<form data-component="form" method="post">
|
||||||
|
<FormAlert
|
||||||
|
message={
|
||||||
|
error?.type
|
||||||
|
? error.type === 'validation_error'
|
||||||
|
? (error.message ?? copy?.[`error_${error.type}`])
|
||||||
|
: copy?.[`error_${error.type}`]
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{state.type === 'start' && (
|
||||||
|
<>
|
||||||
|
<input type="hidden" name="action" value="register" />
|
||||||
|
<input
|
||||||
|
data-component="input"
|
||||||
|
autofocus={!error || emailError}
|
||||||
|
type="email"
|
||||||
|
name="email"
|
||||||
|
value={!emailError ? form?.get('email')?.toString() : ''}
|
||||||
|
required
|
||||||
|
placeholder={copy.input_email}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
data-component="input"
|
||||||
|
autofocus={passwordError}
|
||||||
|
type="password"
|
||||||
|
name="password"
|
||||||
|
placeholder={copy.input_password}
|
||||||
|
required
|
||||||
|
value={!passwordError ? form?.get('password')?.toString() : ''}
|
||||||
|
autoComplete="new-password"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
data-component="input"
|
||||||
|
type="password"
|
||||||
|
name="repeat"
|
||||||
|
required
|
||||||
|
autofocus={passwordError}
|
||||||
|
placeholder={copy.input_repeat}
|
||||||
|
autoComplete="new-password"
|
||||||
|
/>
|
||||||
|
<button data-component="button">{copy.button_continue}</button>
|
||||||
|
<div data-component="form-footer">
|
||||||
|
<span>
|
||||||
|
{copy.login_prompt}{' '}
|
||||||
|
<a data-component="link" href="authorize">
|
||||||
|
{copy.login}
|
||||||
|
</a>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{state.type === 'code' && (
|
||||||
|
<>
|
||||||
|
<input type="hidden" name="action" value="verify" />
|
||||||
|
<input
|
||||||
|
data-component="input"
|
||||||
|
autofocus
|
||||||
|
name="code"
|
||||||
|
minLength={6}
|
||||||
|
maxLength={6}
|
||||||
|
required
|
||||||
|
placeholder={copy.input_code}
|
||||||
|
autoComplete="one-time-code"
|
||||||
|
/>
|
||||||
|
<button data-component="button">{copy.button_continue}</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
</Layout>
|
||||||
|
) as string;
|
||||||
|
return new Response(jsx.toString(), {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'text/html'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
change: async (_req, state, form, error): Promise<Response> => {
|
||||||
|
const passwordError = ['invalid_password', 'password_mismatch', 'validation_error'].includes(
|
||||||
|
error?.type || ''
|
||||||
|
);
|
||||||
|
const jsx = (
|
||||||
|
<Layout>
|
||||||
|
<form data-component="form" method="post" replace>
|
||||||
|
<FormAlert
|
||||||
|
message={
|
||||||
|
error?.type
|
||||||
|
? error.type === 'validation_error'
|
||||||
|
? (error.message ?? copy?.[`error_${error.type}`])
|
||||||
|
: copy?.[`error_${error.type}`]
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{state.type === 'start' && (
|
||||||
|
<>
|
||||||
|
<input type="hidden" name="action" value="code" />
|
||||||
|
<input
|
||||||
|
data-component="input"
|
||||||
|
autofocus
|
||||||
|
type="email"
|
||||||
|
name="email"
|
||||||
|
required
|
||||||
|
value={form?.get('email')?.toString()}
|
||||||
|
placeholder={copy.input_email}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{state.type === 'code' && (
|
||||||
|
<>
|
||||||
|
<input type="hidden" name="action" value="verify" />
|
||||||
|
<input
|
||||||
|
data-component="input"
|
||||||
|
autofocus
|
||||||
|
name="code"
|
||||||
|
minLength={6}
|
||||||
|
maxLength={6}
|
||||||
|
required
|
||||||
|
placeholder={copy.input_code}
|
||||||
|
autoComplete="one-time-code"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{state.type === 'update' && (
|
||||||
|
<>
|
||||||
|
<input type="hidden" name="action" value="update" />
|
||||||
|
<input
|
||||||
|
data-component="input"
|
||||||
|
autofocus
|
||||||
|
type="password"
|
||||||
|
name="password"
|
||||||
|
placeholder={copy.input_password}
|
||||||
|
required
|
||||||
|
value={!passwordError ? form?.get('password')?.toString() : ''}
|
||||||
|
autoComplete="new-password"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
data-component="input"
|
||||||
|
type="password"
|
||||||
|
name="repeat"
|
||||||
|
required
|
||||||
|
value={!passwordError ? form?.get('password')?.toString() : ''}
|
||||||
|
placeholder={copy.input_repeat}
|
||||||
|
autoComplete="new-password"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<button data-component="button">{copy.button_continue}</button>
|
||||||
|
</form>
|
||||||
|
{state.type === 'code' && (
|
||||||
|
<form method="post">
|
||||||
|
<input type="hidden" name="action" value="code" />
|
||||||
|
<input type="hidden" name="email" value={state.email} />
|
||||||
|
{state.type === 'code' && (
|
||||||
|
<div data-component="form-footer">
|
||||||
|
<span>
|
||||||
|
{copy.code_return}{' '}
|
||||||
|
<a data-component="link" href="authorize">
|
||||||
|
{copy.login.toLowerCase()}
|
||||||
|
</a>
|
||||||
|
</span>
|
||||||
|
<button data-component="link">{copy.code_resend}</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
return new Response(jsx.toString(), {
|
||||||
|
status: error ? 400 : 200,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'text/html'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
201
packages/auth/src/ui/select.tsx
Normal file
201
packages/auth/src/ui/select.tsx
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
/**
|
||||||
|
* The UI that's displayed when loading the root page of the OpenAuth server. You can configure
|
||||||
|
* which providers should be displayed in the select UI.
|
||||||
|
*
|
||||||
|
* ```ts
|
||||||
|
* import { Select } from "@openauthjs/openauth/ui/select"
|
||||||
|
*
|
||||||
|
* export default issuer({
|
||||||
|
* select: Select({
|
||||||
|
* providers: {
|
||||||
|
* github: {
|
||||||
|
* hide: true
|
||||||
|
* },
|
||||||
|
* google: {
|
||||||
|
* display: "Google"
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
* })
|
||||||
|
* // ...
|
||||||
|
* })
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @packageDocumentation
|
||||||
|
*/
|
||||||
|
/** @jsxImportSource hono/jsx */
|
||||||
|
|
||||||
|
import { Layout } from './base.js';
|
||||||
|
import { ICON_GITHUB, ICON_GOOGLE } from './icon.js';
|
||||||
|
|
||||||
|
export interface SelectProps {
|
||||||
|
/**
|
||||||
|
* An object with all the providers and their config; where the key is the provider name.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* {
|
||||||
|
* github: {
|
||||||
|
* hide: true
|
||||||
|
* },
|
||||||
|
* google: {
|
||||||
|
* display: "Google"
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
providers?: Record<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Whether to hide the provider from the select UI.
|
||||||
|
* @default false
|
||||||
|
*/
|
||||||
|
hide?: boolean;
|
||||||
|
/**
|
||||||
|
* The display name of the provider.
|
||||||
|
*/
|
||||||
|
display?: string;
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Select(props?: SelectProps) {
|
||||||
|
return async (providers: Record<string, string>, _req: Request): Promise<Response> => {
|
||||||
|
const jsx = (
|
||||||
|
<Layout>
|
||||||
|
<div data-component="form">
|
||||||
|
{Object.entries(providers).map(([key, type]) => {
|
||||||
|
const match = props?.providers?.[key];
|
||||||
|
if (match?.hide) return;
|
||||||
|
const icon = ICON[key];
|
||||||
|
return (
|
||||||
|
<a href={`/${key}/authorize`} data-component="button" data-color="ghost">
|
||||||
|
{icon && <i data-slot="icon">{icon}</i>}
|
||||||
|
Continue with {match?.display || DISPLAY[type] || type}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
|
||||||
|
return new Response(jsx.toString(), {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'text/html'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const DISPLAY: Record<string, string> = {
|
||||||
|
twitch: 'Twitch',
|
||||||
|
google: 'Google',
|
||||||
|
github: 'GitHub',
|
||||||
|
apple: 'Apple',
|
||||||
|
x: 'X',
|
||||||
|
facebook: 'Facebook',
|
||||||
|
microsoft: 'Microsoft',
|
||||||
|
slack: 'Slack'
|
||||||
|
};
|
||||||
|
|
||||||
|
const ICON: Record<string, any> = {
|
||||||
|
code: (
|
||||||
|
<svg
|
||||||
|
fill="currentColor"
|
||||||
|
viewBox="0 0 52 52"
|
||||||
|
data-name="Layer 1"
|
||||||
|
xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path
|
||||||
|
d="M8.55,36.91A6.55,6.55,0,1,1,2,43.45,6.54,6.54,0,0,1,8.55,36.91Zm17.45,0a6.55,6.55,0,1,1-6.55,6.54A6.55,6.55,0,0,1,26,36.91Zm17.45,0a6.55,6.55,0,1,1-6.54,6.54A6.54,6.54,0,0,1,43.45,36.91ZM8.55,19.45A6.55,6.55,0,1,1,2,26,6.55,6.55,0,0,1,8.55,19.45Zm17.45,0A6.55,6.55,0,1,1,19.45,26,6.56,6.56,0,0,1,26,19.45Zm17.45,0A6.55,6.55,0,1,1,36.91,26,6.55,6.55,0,0,1,43.45,19.45ZM8.55,2A6.55,6.55,0,1,1,2,8.55,6.54,6.54,0,0,1,8.55,2ZM26,2a6.55,6.55,0,1,1-6.55,6.55A6.55,6.55,0,0,1,26,2ZM43.45,2a6.55,6.55,0,1,1-6.54,6.55A6.55,6.55,0,0,1,43.45,2Z"
|
||||||
|
fill-rule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
password: (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path
|
||||||
|
fill-rule="evenodd"
|
||||||
|
d="M12 1.5a5.25 5.25 0 0 0-5.25 5.25v3a3 3 0 0 0-3 3v6.75a3 3 0 0 0 3 3h10.5a3 3 0 0 0 3-3v-6.75a3 3 0 0 0-3-3v-3c0-2.9-2.35-5.25-5.25-5.25Zm3.75 8.25v-3a3.75 3.75 0 1 0-7.5 0v3h7.5Z"
|
||||||
|
clip-rule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
twitch: (
|
||||||
|
<svg role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512">
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="M40.1 32L10 108.9v314.3h107V480h60.2l56.8-56.8h87l117-117V32H40.1zm357.8 254.1L331 353H224l-56.8 56.8V353H76.9V72.1h321v214zM331 149v116.9h-40.1V149H331zm-107 0v116.9h-40.1V149H224z"></path>
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
google: ICON_GOOGLE,
|
||||||
|
github: ICON_GITHUB,
|
||||||
|
apple: (
|
||||||
|
<svg role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 814 1000">
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="M788.1 340.9c-5.8 4.5-108.2 62.2-108.2 190.5 0 148.4 130.3 200.9 134.2 202.2-.6 3.2-20.7 71.9-68.7 141.9-42.8 61.6-87.5 123.1-155.5 123.1s-85.5-39.5-164-39.5c-76.5 0-103.7 40.8-165.9 40.8s-105.6-57-155.5-127C46.7 790.7 0 663 0 541.8c0-194.4 126.4-297.5 250.8-297.5 66.1 0 121.2 43.4 162.7 43.4 39.5 0 101.1-46 176.3-46 28.5 0 130.9 2.6 198.3 99.2zm-234-181.5c31.1-36.9 53.1-88.1 53.1-139.3 0-7.1-.6-14.3-1.9-20.1-50.6 1.9-110.8 33.7-147.1 75.8-28.5 32.4-55.1 83.6-55.1 135.5 0 7.8 1.3 15.6 1.9 18.1 3.2.6 8.4 1.3 13.6 1.3 45.4 0 102.5-30.4 135.5-71.3z "
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
x: (
|
||||||
|
<svg role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 1227">
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="M714.163 519.284 1160.89 0h-105.86L667.137 450.887 357.328 0H0l468.492 681.821L0 1226.37h105.866l409.625-476.152 327.181 476.152H1200L714.137 519.284h.026ZM569.165 687.828l-47.468-67.894-377.686-540.24h162.604l304.797 435.991 47.468 67.894 396.2 566.721H892.476L569.165 687.854v-.026Z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
microsoft: (
|
||||||
|
<svg
|
||||||
|
role="img"
|
||||||
|
viewBox="0 0 256 256"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
preserveAspectRatio="xMidYMid">
|
||||||
|
<path fill="#F1511B" d="M121.666 121.666H0V0h121.666z" />
|
||||||
|
<path fill="#80CC28" d="M256 121.666H134.335V0H256z" />
|
||||||
|
<path fill="#00ADEF" d="M121.663 256.002H0V134.336h121.663z" />
|
||||||
|
<path fill="#FBBC09" d="M256 256.002H134.335V134.336H256z" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
facebook: (
|
||||||
|
<svg role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 36 36" fill="url(#a)">
|
||||||
|
<defs>
|
||||||
|
<linearGradient x1="50%" x2="50%" y1="97.078%" y2="0%" id="a">
|
||||||
|
<stop offset="0%" stop-color="#0062E0" />
|
||||||
|
<stop offset="100%" stop-color="#19AFFF" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<path d="M15 35.8C6.5 34.3 0 26.9 0 18 0 8.1 8.1 0 18 0s18 8.1 18 18c0 8.9-6.5 16.3-15 17.8l-1-.8h-4l-1 .8z" />
|
||||||
|
<path
|
||||||
|
fill="#FFF"
|
||||||
|
d="m25 23 .8-5H21v-3.5c0-1.4.5-2.5 2.7-2.5H26V7.4c-1.3-.2-2.7-.4-4-.4-4.1 0-7 2.5-7 7v4h-4.5v5H15v12.7c1 .2 2 .3 3 .3s2-.1 3-.3V23h4z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
slack: (
|
||||||
|
<svg
|
||||||
|
role="img"
|
||||||
|
enable-background="new 0 0 2447.6 2452.5"
|
||||||
|
viewBox="0 0 2447.6 2452.5"
|
||||||
|
xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g clip-rule="evenodd" fill-rule="evenodd">
|
||||||
|
<path
|
||||||
|
d="m897.4 0c-135.3.1-244.8 109.9-244.7 245.2-.1 135.3 109.5 245.1 244.8 245.2h244.8v-245.1c.1-135.3-109.5-245.1-244.9-245.3.1 0 .1 0 0 0m0 654h-652.6c-135.3.1-244.9 109.9-244.8 245.2-.2 135.3 109.4 245.1 244.7 245.3h652.7c135.3-.1 244.9-109.9 244.8-245.2.1-135.4-109.5-245.2-244.8-245.3z"
|
||||||
|
fill="#36c5f0"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="m2447.6 899.2c.1-135.3-109.5-245.1-244.8-245.2-135.3.1-244.9 109.9-244.8 245.2v245.3h244.8c135.3-.1 244.9-109.9 244.8-245.3zm-652.7 0v-654c.1-135.2-109.4-245-244.7-245.2-135.3.1-244.9 109.9-244.8 245.2v654c-.2 135.3 109.4 245.1 244.7 245.3 135.3-.1 244.9-109.9 244.8-245.3z"
|
||||||
|
fill="#2eb67d"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="m1550.1 2452.5c135.3-.1 244.9-109.9 244.8-245.2.1-135.3-109.5-245.1-244.8-245.2h-244.8v245.2c-.1 135.2 109.5 245 244.8 245.2zm0-654.1h652.7c135.3-.1 244.9-109.9 244.8-245.2.2-135.3-109.4-245.1-244.7-245.3h-652.7c-135.3.1-244.9 109.9-244.8 245.2-.1 135.4 109.4 245.2 244.7 245.3z"
|
||||||
|
fill="#ecb22e"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="m0 1553.2c-.1 135.3 109.5 245.1 244.8 245.2 135.3-.1 244.9-109.9 244.8-245.2v-245.2h-244.8c-135.3.1-244.9 109.9-244.8 245.2zm652.7 0v654c-.2 135.3 109.4 245.1 244.7 245.3 135.3-.1 244.9-109.9 244.8-245.2v-653.9c.2-135.3-109.4-245.1-244.7-245.3-135.4 0-244.9 109.8-244.8 245.1 0 0 0 .1 0 0"
|
||||||
|
fill="#e01e5a"
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
};
|
||||||
318
packages/auth/src/ui/theme.ts
Normal file
318
packages/auth/src/ui/theme.ts
Normal file
@@ -0,0 +1,318 @@
|
|||||||
|
/**
|
||||||
|
* Use one of the built-in themes.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
*
|
||||||
|
* ```ts
|
||||||
|
* import { THEME_SST } from "@openauthjs/openauth/ui/theme"
|
||||||
|
*
|
||||||
|
* export default issuer({
|
||||||
|
* theme: THEME_SST,
|
||||||
|
* // ...
|
||||||
|
* })
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* Or define your own.
|
||||||
|
*
|
||||||
|
* ```ts
|
||||||
|
* import type { Theme } from "@openauthjs/openauth/ui/theme"
|
||||||
|
*
|
||||||
|
* const MY_THEME: Theme = {
|
||||||
|
* title: "Acne",
|
||||||
|
* radius: "none",
|
||||||
|
* favicon: "https://www.example.com/favicon.svg",
|
||||||
|
* // ...
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* export default issuer({
|
||||||
|
* theme: MY_THEME,
|
||||||
|
* // ...
|
||||||
|
* })
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @packageDocumentation
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A type to define values for light and dark mode.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* {
|
||||||
|
* light: "#FFF",
|
||||||
|
* dark: "#000"
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export interface ColorScheme {
|
||||||
|
/**
|
||||||
|
* The value for dark mode.
|
||||||
|
*/
|
||||||
|
dark: string;
|
||||||
|
/**
|
||||||
|
* The value for light mode.
|
||||||
|
*/
|
||||||
|
light: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A type to define your custom theme.
|
||||||
|
*/
|
||||||
|
export interface Theme {
|
||||||
|
/**
|
||||||
|
* The name of your app. Also used as the title of the page.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* {
|
||||||
|
* title: "Acne"
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
title?: string;
|
||||||
|
/**
|
||||||
|
* A URL to the favicon of your app.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* {
|
||||||
|
* favicon: "https://www.example.com/favicon.svg"
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
favicon?: string;
|
||||||
|
/**
|
||||||
|
* The border radius of the UI elements.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* {
|
||||||
|
* radius: "none"
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
radius?: 'none' | 'sm' | 'md' | 'lg' | 'full';
|
||||||
|
/**
|
||||||
|
* The primary color of the theme.
|
||||||
|
*
|
||||||
|
* Takes a color or both light and dark colors.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* {
|
||||||
|
* primary: "#FF5E00"
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
primary: string | ColorScheme;
|
||||||
|
/**
|
||||||
|
* The background color of the theme.
|
||||||
|
*
|
||||||
|
* Takes a color or both light and dark colors.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* {
|
||||||
|
* background: "#FFF"
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
background?: string | ColorScheme;
|
||||||
|
/**
|
||||||
|
* A URL to the logo of your app.
|
||||||
|
*
|
||||||
|
* Takes a single image or both light and dark mode versions.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* {
|
||||||
|
* logo: "https://www.example.com/logo.svg"
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
logo?: string | ColorScheme;
|
||||||
|
/**
|
||||||
|
* The font family and scale of the theme.
|
||||||
|
*/
|
||||||
|
font?: {
|
||||||
|
/**
|
||||||
|
* The font family of the theme.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* {
|
||||||
|
* font: {
|
||||||
|
* family: "Geist Mono, monospace"
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
family?: string;
|
||||||
|
/**
|
||||||
|
* The font scale of the theme. Can be used to increase or decrease the font sizes across
|
||||||
|
* the UI.
|
||||||
|
*
|
||||||
|
* @default "1"
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* {
|
||||||
|
* font: {
|
||||||
|
* scale: "1.25"
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
scale?: string;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* Custom CSS that's added to the page in a `<style>` tag.
|
||||||
|
*
|
||||||
|
* This can be used to import custom fonts.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* {
|
||||||
|
* css: `@import url('https://fonts.googleapis.com/css2?family=Rubik:wght@100;200;300;400;500;600;700;800;900&display=swap');`
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
css?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Built-in default OpenAuth theme.
|
||||||
|
*/
|
||||||
|
export const THEME_OPENAUTH: Theme = {
|
||||||
|
title: 'OpenAuth',
|
||||||
|
radius: 'none',
|
||||||
|
background: {
|
||||||
|
dark: 'black',
|
||||||
|
light: 'white'
|
||||||
|
},
|
||||||
|
primary: {
|
||||||
|
dark: 'white',
|
||||||
|
light: 'black'
|
||||||
|
},
|
||||||
|
font: {
|
||||||
|
family: 'IBM Plex Sans, sans-serif'
|
||||||
|
},
|
||||||
|
css: `
|
||||||
|
@import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@100;200;300;400;500;600;700&display=swap');
|
||||||
|
`
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Built-in theme based on [Terminal](https://terminal.shop).
|
||||||
|
*/
|
||||||
|
export const THEME_TERMINAL: Theme = {
|
||||||
|
title: 'terminal',
|
||||||
|
radius: 'none',
|
||||||
|
favicon: 'https://www.terminal.shop/favicon.svg',
|
||||||
|
logo: {
|
||||||
|
dark: 'https://www.terminal.shop/images/logo-white.svg',
|
||||||
|
light: 'https://www.terminal.shop/images/logo-black.svg'
|
||||||
|
},
|
||||||
|
primary: '#ff5e00',
|
||||||
|
background: {
|
||||||
|
dark: 'rgb(0, 0, 0)',
|
||||||
|
light: 'rgb(255, 255, 255)'
|
||||||
|
},
|
||||||
|
font: {
|
||||||
|
family: 'Geist Mono, monospace'
|
||||||
|
},
|
||||||
|
css: `
|
||||||
|
@import url('https://fonts.googleapis.com/css2?family=Geist+Mono:wght@100;200;300;400;500;600;700;800;900&display=swap');
|
||||||
|
`
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Built-in theme based on [SST](https://sst.dev).
|
||||||
|
*/
|
||||||
|
export const THEME_SST: Theme = {
|
||||||
|
title: 'SST',
|
||||||
|
favicon: 'https://sst.dev/favicon.svg',
|
||||||
|
logo: {
|
||||||
|
dark: 'https://sst.dev/favicon.svg',
|
||||||
|
light: 'https://sst.dev/favicon.svg'
|
||||||
|
},
|
||||||
|
background: {
|
||||||
|
dark: '#1a1a2d',
|
||||||
|
light: 'rgb(255, 255, 255)'
|
||||||
|
},
|
||||||
|
primary: '#f3663f',
|
||||||
|
font: {
|
||||||
|
family: 'Rubik, sans-serif'
|
||||||
|
},
|
||||||
|
css: `
|
||||||
|
@import url('https://fonts.googleapis.com/css2?family=Rubik:wght@100;200;300;400;500;600;700;800;900&display=swap');
|
||||||
|
`
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Built-in theme based on [Supabase](https://supabase.com).
|
||||||
|
*/
|
||||||
|
export const THEME_SUPABASE: Theme = {
|
||||||
|
title: 'Supabase',
|
||||||
|
logo: {
|
||||||
|
dark: 'https://supabase.com/dashboard/_next/image?url=%2Fdashboard%2Fimg%2Fsupabase-dark.svg&w=128&q=75',
|
||||||
|
light:
|
||||||
|
'https://supabase.com/dashboard/_next/image?url=%2Fdashboard%2Fimg%2Fsupabase-light.svg&w=128&q=75'
|
||||||
|
},
|
||||||
|
background: {
|
||||||
|
dark: '#171717',
|
||||||
|
light: '#f8f8f8'
|
||||||
|
},
|
||||||
|
primary: {
|
||||||
|
dark: '#006239',
|
||||||
|
light: '#72e3ad'
|
||||||
|
},
|
||||||
|
font: {
|
||||||
|
family: 'Varela Round, sans-serif'
|
||||||
|
},
|
||||||
|
css: `
|
||||||
|
@import url('https://fonts.googleapis.com/css2?family=Varela+Round:wght@100;200;300;400;500;600;700;800;900&display=swap');
|
||||||
|
`
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Built-in theme based on [Vercel](https://vercel.com).
|
||||||
|
*/
|
||||||
|
export const THEME_VERCEL: Theme = {
|
||||||
|
title: 'Vercel',
|
||||||
|
logo: {
|
||||||
|
dark: 'https://vercel.com/mktng/_next/static/media/vercel-logotype-dark.e8c0a742.svg',
|
||||||
|
light: 'https://vercel.com/mktng/_next/static/media/vercel-logotype-light.700a8d26.svg'
|
||||||
|
},
|
||||||
|
background: {
|
||||||
|
dark: 'black',
|
||||||
|
light: 'white'
|
||||||
|
},
|
||||||
|
primary: {
|
||||||
|
dark: 'white',
|
||||||
|
light: 'black'
|
||||||
|
},
|
||||||
|
font: {
|
||||||
|
family: 'Geist, sans-serif'
|
||||||
|
},
|
||||||
|
css: `
|
||||||
|
@import url('https://fonts.googleapis.com/css2?family=Geist:wght@100;200;300;400;500;600;700;800;900&display=swap');
|
||||||
|
`
|
||||||
|
};
|
||||||
|
|
||||||
|
// i really don't wanna use async local storage for this so get over it
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
export function setTheme(value: Theme) {
|
||||||
|
// @ts-ignore
|
||||||
|
globalThis.OPENAUTH_THEME = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
export function getTheme() {
|
||||||
|
// @ts-ignore
|
||||||
|
return globalThis.OPENAUTH_THEME || THEME_OPENAUTH;
|
||||||
|
}
|
||||||
56
packages/auth/src/util.ts
Normal file
56
packages/auth/src/util.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import type { Context } from 'hono';
|
||||||
|
|
||||||
|
export type Prettify<T> = {
|
||||||
|
[K in keyof T]: T[K];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getRelativeUrl(ctx: Context, path: string) {
|
||||||
|
const result = new URL(path, ctx.req.url);
|
||||||
|
result.host = ctx.req.header('x-forwarded-host') || result.host;
|
||||||
|
result.protocol = ctx.req.header('x-forwarded-proto') || result.protocol;
|
||||||
|
result.port = ctx.req.header('x-forwarded-port') || result.port;
|
||||||
|
return result.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
const twoPartTlds = [
|
||||||
|
'co.uk',
|
||||||
|
'co.jp',
|
||||||
|
'co.kr',
|
||||||
|
'co.nz',
|
||||||
|
'co.za',
|
||||||
|
'co.in',
|
||||||
|
'com.au',
|
||||||
|
'com.br',
|
||||||
|
'com.cn',
|
||||||
|
'com.mx',
|
||||||
|
'com.tw',
|
||||||
|
'net.au',
|
||||||
|
'org.uk',
|
||||||
|
'ne.jp',
|
||||||
|
'ac.uk',
|
||||||
|
'gov.uk',
|
||||||
|
'edu.au',
|
||||||
|
'gov.au'
|
||||||
|
];
|
||||||
|
|
||||||
|
export function isDomainMatch(a: string, b: string): boolean {
|
||||||
|
if (a === b) return true;
|
||||||
|
const partsA = a.split('.');
|
||||||
|
const partsB = b.split('.');
|
||||||
|
const hasTwoPartTld = twoPartTlds.some((tld) => a.endsWith('.' + tld) || b.endsWith('.' + tld));
|
||||||
|
const numParts = hasTwoPartTld ? -3 : -2;
|
||||||
|
const min = Math.min(partsA.length, partsB.length, numParts);
|
||||||
|
const tailA = partsA.slice(min).join('.');
|
||||||
|
const tailB = partsB.slice(min).join('.');
|
||||||
|
return tailA === tailB;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function lazy<T>(fn: () => T): () => T {
|
||||||
|
let value: T | undefined;
|
||||||
|
return () => {
|
||||||
|
if (value === undefined) {
|
||||||
|
value = fn();
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
}
|
||||||
149
packages/auth/test/client.test.ts
Normal file
149
packages/auth/test/client.test.ts
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
import {
|
||||||
|
expect,
|
||||||
|
test,
|
||||||
|
setSystemTime,
|
||||||
|
describe,
|
||||||
|
beforeEach,
|
||||||
|
afterEach,
|
||||||
|
spyOn,
|
||||||
|
afterAll,
|
||||||
|
mock
|
||||||
|
} from 'bun:test';
|
||||||
|
|
||||||
|
import { object, string } from 'valibot';
|
||||||
|
|
||||||
|
import { createClient } from '../src/client.js';
|
||||||
|
import { InvalidAccessTokenError, InvalidRefreshTokenError } from '../src/error.js';
|
||||||
|
import { issuer } from '../src/issuer.js';
|
||||||
|
import { MemoryStorage } from '../src/storage/memory.js';
|
||||||
|
import { createSubjects } from '../src/subject.js';
|
||||||
|
|
||||||
|
const subjects = createSubjects({
|
||||||
|
user: object({
|
||||||
|
userID: string()
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
let storage = MemoryStorage();
|
||||||
|
const auth = issuer({
|
||||||
|
storage,
|
||||||
|
subjects,
|
||||||
|
allow: async () => true,
|
||||||
|
success: async (ctx) => {
|
||||||
|
return ctx.subject('user', {
|
||||||
|
userID: '123'
|
||||||
|
});
|
||||||
|
},
|
||||||
|
ttl: {
|
||||||
|
access: 60
|
||||||
|
},
|
||||||
|
providers: {
|
||||||
|
dummy: {
|
||||||
|
type: 'dummy',
|
||||||
|
init(route, ctx) {
|
||||||
|
route.get('/authorize', async (c) => {
|
||||||
|
return ctx.success(c, {
|
||||||
|
email: 'foo@bar.com'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const expectNonEmptyString = expect.stringMatching(/.+/);
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
setSystemTime(new Date('1/1/2024'));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
setSystemTime();
|
||||||
|
});
|
||||||
|
|
||||||
|
const consoleSpy = spyOn(console, 'error').mockImplementation(mock());
|
||||||
|
afterAll(() => {
|
||||||
|
consoleSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('verify', () => {
|
||||||
|
let tokens: { access: string; refresh: string };
|
||||||
|
let client: ReturnType<typeof createClient>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
client = createClient({
|
||||||
|
// use different issuer per test file to avoid JWKS cache issues
|
||||||
|
issuer: 'https://auth1.example.com',
|
||||||
|
clientID: '123',
|
||||||
|
fetch: (a, b) => Promise.resolve(auth.request(a, b))
|
||||||
|
});
|
||||||
|
const [verifier, authorization] = await client.pkce('https://client.example.com/callback');
|
||||||
|
let response = await auth.request(authorization);
|
||||||
|
response = await auth.request(response.headers.get('location')!, {
|
||||||
|
headers: {
|
||||||
|
cookie: response.headers.get('set-cookie')!
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const location = new URL(response.headers.get('location')!);
|
||||||
|
const code = location.searchParams.get('code');
|
||||||
|
const exchanged = await client.exchange(code!, 'https://client.example.com/callback', verifier);
|
||||||
|
if (exchanged.err) throw exchanged.err;
|
||||||
|
tokens = exchanged.tokens;
|
||||||
|
});
|
||||||
|
|
||||||
|
test('success', async () => {
|
||||||
|
const refreshSpy = spyOn(client, 'refresh');
|
||||||
|
const verified = await client.verify(subjects, tokens.access);
|
||||||
|
expect(verified).toStrictEqual({
|
||||||
|
aud: '123',
|
||||||
|
subject: {
|
||||||
|
type: 'user',
|
||||||
|
properties: {
|
||||||
|
userID: '123'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
expect(refreshSpy).not.toBeCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('success after refresh', async () => {
|
||||||
|
const refreshSpy = spyOn(client, 'refresh');
|
||||||
|
setSystemTime(Date.now() + 1000 * 6000 + 1000);
|
||||||
|
const verified = await client.verify(subjects, tokens.access, {
|
||||||
|
refresh: tokens.refresh
|
||||||
|
});
|
||||||
|
expect(verified).toStrictEqual({
|
||||||
|
aud: '123',
|
||||||
|
tokens: {
|
||||||
|
expiresIn: 60,
|
||||||
|
access: expectNonEmptyString,
|
||||||
|
refresh: expectNonEmptyString
|
||||||
|
},
|
||||||
|
subject: {
|
||||||
|
type: 'user',
|
||||||
|
properties: {
|
||||||
|
userID: '123'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
expect(refreshSpy).toBeCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('failure with expired access token', async () => {
|
||||||
|
setSystemTime(Date.now() + 1000 * 6000 + 1000);
|
||||||
|
const verified = await client.verify(subjects, tokens.access);
|
||||||
|
expect(verified).toStrictEqual({
|
||||||
|
err: expect.any(InvalidAccessTokenError)
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('failure with invalid refresh token', async () => {
|
||||||
|
setSystemTime(Date.now() + 1000 * 6000 + 1000);
|
||||||
|
const verified = await client.verify(subjects, tokens.access, {
|
||||||
|
refresh: 'foo'
|
||||||
|
});
|
||||||
|
expect(verified).toStrictEqual({
|
||||||
|
err: expect.any(InvalidRefreshTokenError)
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
385
packages/auth/test/issuer.test.ts
Normal file
385
packages/auth/test/issuer.test.ts
Normal file
@@ -0,0 +1,385 @@
|
|||||||
|
import { expect, test, setSystemTime, describe, beforeEach, afterEach } from 'bun:test';
|
||||||
|
|
||||||
|
import { object, string } from 'valibot';
|
||||||
|
|
||||||
|
import { createClient } from '../src/client.js';
|
||||||
|
import { issuer } from '../src/issuer.js';
|
||||||
|
import { Provider } from '../src/provider/provider.js';
|
||||||
|
import { MemoryStorage } from '../src/storage/memory.js';
|
||||||
|
import { createSubjects } from '../src/subject.js';
|
||||||
|
|
||||||
|
const subjects = createSubjects({
|
||||||
|
user: object({
|
||||||
|
userID: string()
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
let storage = MemoryStorage();
|
||||||
|
const issuerConfig = {
|
||||||
|
storage,
|
||||||
|
subjects,
|
||||||
|
allow: async () => true,
|
||||||
|
ttl: {
|
||||||
|
access: 60,
|
||||||
|
refresh: 6000,
|
||||||
|
refreshReuse: 60,
|
||||||
|
refreshRetention: 6000
|
||||||
|
},
|
||||||
|
providers: {
|
||||||
|
dummy: {
|
||||||
|
type: 'dummy',
|
||||||
|
init(route, ctx) {
|
||||||
|
route.get('/authorize', async (c) => {
|
||||||
|
return ctx.success(c, {
|
||||||
|
email: 'foo@bar.com'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
client: async ({ clientID, clientSecret }) => {
|
||||||
|
if (clientID !== 'myuser' && clientSecret !== 'mypass') {
|
||||||
|
throw new Error('Wrong credentials');
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
email: 'foo@bar.com'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} satisfies Provider<{ email: string }>
|
||||||
|
},
|
||||||
|
success: async (ctx, value) => {
|
||||||
|
if (value.provider === 'dummy') {
|
||||||
|
return ctx.subject('user', {
|
||||||
|
userID: '123'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
throw new Error('Invalid provider: ' + value.provider);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const auth = issuer(issuerConfig);
|
||||||
|
|
||||||
|
const expectNonEmptyString = expect.stringMatching(/.+/);
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
setSystemTime(new Date('1/1/2024'));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
setSystemTime();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('code flow', () => {
|
||||||
|
test('success', async () => {
|
||||||
|
const client = createClient({
|
||||||
|
issuer: 'https://auth.example.com',
|
||||||
|
clientID: '123',
|
||||||
|
fetch: (a, b) => Promise.resolve(auth.request(a, b))
|
||||||
|
});
|
||||||
|
const { challenge, url } = await client.authorize(
|
||||||
|
'https://client.example.com/callback',
|
||||||
|
'code',
|
||||||
|
{
|
||||||
|
pkce: true
|
||||||
|
}
|
||||||
|
);
|
||||||
|
let response = await auth.request(url);
|
||||||
|
expect(response.status).toBe(302);
|
||||||
|
response = await auth.request(response.headers.get('location')!, {
|
||||||
|
headers: {
|
||||||
|
cookie: response.headers.get('set-cookie')!
|
||||||
|
}
|
||||||
|
});
|
||||||
|
expect(response.status).toBe(302);
|
||||||
|
const location = new URL(response.headers.get('location')!);
|
||||||
|
const code = location.searchParams.get('code');
|
||||||
|
expect(code).not.toBeNull();
|
||||||
|
const exchanged = await client.exchange(
|
||||||
|
code!,
|
||||||
|
'https://client.example.com/callback',
|
||||||
|
challenge.verifier
|
||||||
|
);
|
||||||
|
if (exchanged.err) throw exchanged.err;
|
||||||
|
const tokens = exchanged.tokens;
|
||||||
|
expect(tokens).toStrictEqual({
|
||||||
|
access: expectNonEmptyString,
|
||||||
|
refresh: expectNonEmptyString,
|
||||||
|
expiresIn: 60
|
||||||
|
});
|
||||||
|
const verified = await client.verify(subjects, tokens.access);
|
||||||
|
if (verified.err) throw verified.err;
|
||||||
|
expect(verified.subject).toStrictEqual({
|
||||||
|
type: 'user',
|
||||||
|
properties: {
|
||||||
|
userID: '123'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('client credentials flow', () => {
|
||||||
|
test('success', async () => {
|
||||||
|
const client = createClient({
|
||||||
|
issuer: 'https://auth.example.com',
|
||||||
|
clientID: '123',
|
||||||
|
fetch: (a, b) => Promise.resolve(auth.request(a, b))
|
||||||
|
});
|
||||||
|
const response = await auth.request('https://auth.example.com/token', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded'
|
||||||
|
},
|
||||||
|
body: new URLSearchParams({
|
||||||
|
grant_type: 'client_credentials',
|
||||||
|
provider: 'dummy',
|
||||||
|
client_id: 'myuser',
|
||||||
|
client_secret: 'mypass'
|
||||||
|
}).toString()
|
||||||
|
});
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
const tokens = await response.json();
|
||||||
|
expect(tokens).toStrictEqual({
|
||||||
|
access_token: expectNonEmptyString,
|
||||||
|
refresh_token: expectNonEmptyString
|
||||||
|
});
|
||||||
|
const verified = await client.verify(subjects, tokens.access_token);
|
||||||
|
expect(verified).toStrictEqual({
|
||||||
|
aud: 'myuser',
|
||||||
|
subject: {
|
||||||
|
type: 'user',
|
||||||
|
properties: {
|
||||||
|
userID: '123'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('refresh token', () => {
|
||||||
|
let tokens: { access: string; refresh: string };
|
||||||
|
let client: ReturnType<typeof createClient>;
|
||||||
|
|
||||||
|
const generateTokens = async (issuer: typeof auth) => {
|
||||||
|
const { challenge, url } = await client.authorize(
|
||||||
|
'https://client.example.com/callback',
|
||||||
|
'code',
|
||||||
|
{
|
||||||
|
pkce: true
|
||||||
|
}
|
||||||
|
);
|
||||||
|
let response = await issuer.request(url);
|
||||||
|
response = await issuer.request(response.headers.get('location')!, {
|
||||||
|
headers: {
|
||||||
|
cookie: response.headers.get('set-cookie')!
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const location = new URL(response.headers.get('location')!);
|
||||||
|
const code = location.searchParams.get('code');
|
||||||
|
const exchanged = await client.exchange(
|
||||||
|
code!,
|
||||||
|
'https://client.example.com/callback',
|
||||||
|
challenge.verifier
|
||||||
|
);
|
||||||
|
if (exchanged.err) throw exchanged.err;
|
||||||
|
return exchanged.tokens;
|
||||||
|
};
|
||||||
|
|
||||||
|
const createClientAndTokens = async (issuer: typeof auth) => {
|
||||||
|
client = createClient({
|
||||||
|
issuer: 'https://auth.example.com',
|
||||||
|
clientID: '123',
|
||||||
|
fetch: (a, b) => Promise.resolve(issuer.request(a, b))
|
||||||
|
});
|
||||||
|
tokens = await generateTokens(issuer);
|
||||||
|
};
|
||||||
|
|
||||||
|
const requestRefreshToken = async (refresh_token: string, issuer?: typeof auth) =>
|
||||||
|
(issuer ?? auth).request('https://auth.example.com/token', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded'
|
||||||
|
},
|
||||||
|
body: new URLSearchParams({
|
||||||
|
grant_type: 'refresh_token',
|
||||||
|
...(refresh_token ? { refresh_token } : {})
|
||||||
|
}).toString()
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await createClientAndTokens(auth);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('success', async () => {
|
||||||
|
setSystemTime(Date.now() + 1000 * 60 + 1000);
|
||||||
|
let response = await requestRefreshToken(tokens.refresh);
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
const refreshed = await response.json();
|
||||||
|
expect(refreshed).toStrictEqual({
|
||||||
|
access_token: expectNonEmptyString,
|
||||||
|
refresh_token: expectNonEmptyString,
|
||||||
|
expires_in: expect.any(Number)
|
||||||
|
});
|
||||||
|
expect(refreshed.access_token).not.toEqual(tokens.access);
|
||||||
|
expect(refreshed.refresh_token).not.toEqual(tokens.refresh);
|
||||||
|
|
||||||
|
const verified = await client.verify(subjects, refreshed.access_token);
|
||||||
|
expect(verified).toStrictEqual({
|
||||||
|
aud: '123',
|
||||||
|
subject: {
|
||||||
|
type: 'user',
|
||||||
|
properties: {
|
||||||
|
userID: '123'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('success with valid access token', async () => {
|
||||||
|
// have to increment the time so new access token claims are different (i.e. exp)
|
||||||
|
setSystemTime(Date.now() + 1000);
|
||||||
|
let response = await requestRefreshToken(tokens.refresh);
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
const refreshed = await response.json();
|
||||||
|
expect(refreshed).toStrictEqual({
|
||||||
|
access_token: expectNonEmptyString,
|
||||||
|
refresh_token: expectNonEmptyString,
|
||||||
|
expires_in: expect.any(Number)
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(refreshed.access_token).not.toEqual(tokens.access);
|
||||||
|
expect(refreshed.refresh_token).not.toEqual(tokens.refresh);
|
||||||
|
|
||||||
|
const verified = await client.verify(subjects, refreshed.access_token);
|
||||||
|
expect(verified).toStrictEqual({
|
||||||
|
aud: '123',
|
||||||
|
subject: {
|
||||||
|
type: 'user',
|
||||||
|
properties: {
|
||||||
|
userID: '123'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('multiple active tokens', async () => {
|
||||||
|
const tokens2 = await generateTokens(auth);
|
||||||
|
|
||||||
|
let response = await requestRefreshToken(tokens.refresh);
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
|
||||||
|
response = await requestRefreshToken(tokens2.refresh);
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('failure with reuse interval disabled', async () => {
|
||||||
|
const issuerWithoutReuse = issuer({
|
||||||
|
...issuerConfig,
|
||||||
|
ttl: {
|
||||||
|
...issuerConfig.ttl,
|
||||||
|
reuse: 0,
|
||||||
|
retention: 0
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await createClientAndTokens(issuerWithoutReuse);
|
||||||
|
let response = await requestRefreshToken(tokens.refresh, issuerWithoutReuse);
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
|
||||||
|
response = await requestRefreshToken(tokens.refresh, issuerWithoutReuse);
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
const reused = await response.json();
|
||||||
|
expect(reused.error).toBe('invalid_grant');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('success with reuse interval enabled', async () => {
|
||||||
|
let response = await requestRefreshToken(tokens.refresh);
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
const refreshed = await response.json();
|
||||||
|
const [, refreshedAccessPayload] = refreshed.access_token.split('.');
|
||||||
|
|
||||||
|
setSystemTime(Date.now() + 1000 * 30);
|
||||||
|
|
||||||
|
response = await requestRefreshToken(tokens.refresh);
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
const reused = await response.json();
|
||||||
|
const [, reusedAccessPayload] = reused.access_token.split('.');
|
||||||
|
expect(refreshed.refresh_token).toEqual(reused.refresh_token);
|
||||||
|
/**
|
||||||
|
* Access token signature is different every time for ES256 alg,
|
||||||
|
* but the payload should be the same.
|
||||||
|
*/
|
||||||
|
expect(refreshedAccessPayload).toEqual(reusedAccessPayload);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('invalidated with reuse detection', async () => {
|
||||||
|
let response = await requestRefreshToken(tokens.refresh);
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
|
||||||
|
setSystemTime(Date.now() + 1000 * 60 + 1000);
|
||||||
|
|
||||||
|
response = await requestRefreshToken(tokens.refresh);
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('expired failure', async () => {
|
||||||
|
setSystemTime(Date.now() + 1000 * 6000 + 1000);
|
||||||
|
let response = await requestRefreshToken(tokens.refresh);
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
const reused = await response.json();
|
||||||
|
expect(reused.error).toBe('invalid_grant');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('missing failure', async () => {
|
||||||
|
let response = await requestRefreshToken('');
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
const reused = await response.json();
|
||||||
|
expect(reused.error).toBe('invalid_request');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('user info', () => {
|
||||||
|
let tokens: { access: string; refresh: string };
|
||||||
|
let client: ReturnType<typeof createClient>;
|
||||||
|
|
||||||
|
const generateTokens = async (issuer: typeof auth) => {
|
||||||
|
const { challenge, url } = await client.authorize(
|
||||||
|
'https://client.example.com/callback',
|
||||||
|
'code',
|
||||||
|
{ pkce: true }
|
||||||
|
);
|
||||||
|
let response = await issuer.request(url);
|
||||||
|
response = await issuer.request(response.headers.get('location')!, {
|
||||||
|
headers: {
|
||||||
|
cookie: response.headers.get('set-cookie')!
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const location = new URL(response.headers.get('location')!);
|
||||||
|
const code = location.searchParams.get('code');
|
||||||
|
const exchanged = await client.exchange(
|
||||||
|
code!,
|
||||||
|
'https://client.example.com/callback',
|
||||||
|
challenge.verifier
|
||||||
|
);
|
||||||
|
if (exchanged.err) throw exchanged.err;
|
||||||
|
return exchanged.tokens;
|
||||||
|
};
|
||||||
|
|
||||||
|
const createClientAndTokens = async (issuer: typeof auth) => {
|
||||||
|
client = createClient({
|
||||||
|
issuer: 'https://auth.example.com',
|
||||||
|
clientID: '123',
|
||||||
|
fetch: (a, b) => Promise.resolve(issuer.request(a, b))
|
||||||
|
});
|
||||||
|
tokens = await generateTokens(issuer);
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await createClientAndTokens(auth);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('success', async () => {
|
||||||
|
const response = await auth.request('https://auth.example.com/userinfo', {
|
||||||
|
headers: { Authorization: `Bearer ${tokens.access}` }
|
||||||
|
});
|
||||||
|
|
||||||
|
const userinfo = await response.json();
|
||||||
|
|
||||||
|
expect(userinfo).toStrictEqual({ userID: '123' });
|
||||||
|
});
|
||||||
|
});
|
||||||
81
packages/auth/test/scrap.test.ts
Normal file
81
packages/auth/test/scrap.test.ts
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import { expect, test } from 'bun:test';
|
||||||
|
|
||||||
|
import { object, string } from 'valibot';
|
||||||
|
|
||||||
|
import { createClient } from '../src/client.js';
|
||||||
|
import { issuer } from '../src/issuer.js';
|
||||||
|
import { MemoryStorage } from '../src/storage/memory.js';
|
||||||
|
import { createSubjects } from '../src/subject.js';
|
||||||
|
|
||||||
|
const subjects = createSubjects({
|
||||||
|
user: object({
|
||||||
|
userID: string()
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
const auth = issuer({
|
||||||
|
storage: MemoryStorage(),
|
||||||
|
subjects,
|
||||||
|
allow: async () => true,
|
||||||
|
success: async (ctx) => {
|
||||||
|
return ctx.subject('user', {
|
||||||
|
userID: '123'
|
||||||
|
});
|
||||||
|
},
|
||||||
|
ttl: {
|
||||||
|
access: 1
|
||||||
|
},
|
||||||
|
providers: {
|
||||||
|
dummy: {
|
||||||
|
type: 'dummy',
|
||||||
|
init(route, ctx) {
|
||||||
|
route.get('/authorize', async (c) => {
|
||||||
|
return ctx.success(c, {
|
||||||
|
email: 'foo@bar.com'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('code flow', async () => {
|
||||||
|
const client = createClient({
|
||||||
|
issuer: 'https://auth.example.com',
|
||||||
|
clientID: '123',
|
||||||
|
fetch: (a, b) => Promise.resolve(auth.request(a, b))
|
||||||
|
});
|
||||||
|
const [verifier, authorization] = await client.pkce('https://client.example.com/callback');
|
||||||
|
let response = await auth.request(authorization);
|
||||||
|
expect(response.status).toBe(302);
|
||||||
|
response = await auth.request(response.headers.get('location')!, {
|
||||||
|
headers: {
|
||||||
|
cookie: response.headers.get('set-cookie')!
|
||||||
|
}
|
||||||
|
});
|
||||||
|
expect(response.status).toBe(302);
|
||||||
|
const location = new URL(response.headers.get('location')!);
|
||||||
|
const code = location.searchParams.get('code');
|
||||||
|
expect(code).not.toBeNull();
|
||||||
|
const exchanged = await client.exchange(code!, 'https://client.example.com/callback', verifier);
|
||||||
|
if (exchanged.err) throw exchanged.err;
|
||||||
|
expect(exchanged.tokens.access).toBeTruthy();
|
||||||
|
expect(exchanged.tokens.refresh).toBeTruthy();
|
||||||
|
const verified = await client.verify(subjects, exchanged.tokens.access);
|
||||||
|
if (verified.err) throw verified.err;
|
||||||
|
expect(verified.subject.type).toBe('user');
|
||||||
|
if (verified.subject.type !== 'user') throw new Error('Invalid subject');
|
||||||
|
expect(verified.subject.properties.userID).toBe('123');
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||||
|
const failed = await client.verify(subjects, exchanged.tokens.access);
|
||||||
|
expect(failed.err).toBeInstanceOf(Error);
|
||||||
|
const next = await client.verify(subjects, exchanged.tokens.access, {
|
||||||
|
refresh: exchanged.tokens.refresh
|
||||||
|
});
|
||||||
|
if (next.err) throw next.err;
|
||||||
|
expect(next.tokens?.access).toBeDefined();
|
||||||
|
expect(next.tokens?.refresh).toBeDefined();
|
||||||
|
expect(next.tokens?.access).not.toEqual(exchanged.tokens.access);
|
||||||
|
expect(next.tokens?.refresh).not.toEqual(exchanged.tokens.refresh);
|
||||||
|
await client.verify(subjects, next.tokens!.access!);
|
||||||
|
});
|
||||||
91
packages/auth/test/storage.test.ts
Normal file
91
packages/auth/test/storage.test.ts
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import { afterEach, setSystemTime } from 'bun:test';
|
||||||
|
import { beforeEach, describe, expect, test } from 'bun:test';
|
||||||
|
|
||||||
|
import { MemoryStorage } from '../src/storage/memory.js';
|
||||||
|
|
||||||
|
let storage = MemoryStorage();
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
storage = MemoryStorage();
|
||||||
|
setSystemTime(new Date('1/1/2024'));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
setSystemTime();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('set', () => {
|
||||||
|
test('basic', async () => {
|
||||||
|
await storage.set(['users', '123'], { name: 'Test User' });
|
||||||
|
const result = await storage.get(['users', '123']);
|
||||||
|
expect(result).toEqual({ name: 'Test User' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ttl', async () => {
|
||||||
|
await storage.set(['temp', 'key'], { value: 'value' }, new Date(Date.now() + 100)); // 100ms TTL
|
||||||
|
let result = await storage.get(['temp', 'key']);
|
||||||
|
expect(result?.value).toBe('value');
|
||||||
|
|
||||||
|
setSystemTime(Date.now() + 150);
|
||||||
|
result = await storage.get(['temp', 'key']);
|
||||||
|
expect(result).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('nested', async () => {
|
||||||
|
const complexObj = {
|
||||||
|
id: 1,
|
||||||
|
nested: { a: 1, b: { c: 2 } },
|
||||||
|
array: [1, 2, 3]
|
||||||
|
};
|
||||||
|
await storage.set(['complex'], complexObj);
|
||||||
|
const result = await storage.get(['complex']);
|
||||||
|
expect(result).toEqual(complexObj);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('get', () => {
|
||||||
|
test('missing', async () => {
|
||||||
|
const result = await storage.get(['nonexistent']);
|
||||||
|
expect(result).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('key', async () => {
|
||||||
|
await storage.set(['a', 'b', 'c'], { value: 'nested' });
|
||||||
|
const result = await storage.get(['a', 'b', 'c']);
|
||||||
|
expect(result?.value).toBe('nested');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('remove', () => {
|
||||||
|
test('existing', async () => {
|
||||||
|
await storage.set(['test'], 'value');
|
||||||
|
await storage.remove(['test']);
|
||||||
|
const result = await storage.get(['test']);
|
||||||
|
expect(result).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('missing', async () => {
|
||||||
|
expect(storage.remove(['nonexistent'])).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('scan', () => {
|
||||||
|
test('all', async () => {
|
||||||
|
await storage.set(['users', '1'], { id: 1 });
|
||||||
|
await storage.set(['users', '2'], { id: 2 });
|
||||||
|
await storage.set(['other'], { id: 3 });
|
||||||
|
const results = await Array.fromAsync(storage.scan(['users']));
|
||||||
|
expect(results).toHaveLength(2);
|
||||||
|
expect(results).toContainEqual([['users', '1'], { id: 1 }]);
|
||||||
|
expect(results).toContainEqual([['users', '2'], { id: 2 }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ttl', async () => {
|
||||||
|
await storage.set(['temp', '1'], 'a', new Date(Date.now() + 100));
|
||||||
|
await storage.set(['temp', '2'], 'b', new Date(Date.now() + 100));
|
||||||
|
await storage.set(['temp', '3'], 'c');
|
||||||
|
expect(await Array.fromAsync(storage.scan(['temp']))).toHaveLength(3);
|
||||||
|
setSystemTime(Date.now() + 150);
|
||||||
|
expect(await Array.fromAsync(storage.scan(['temp']))).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
100
packages/auth/test/util.test.ts
Normal file
100
packages/auth/test/util.test.ts
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
import { expect, test } from 'bun:test';
|
||||||
|
|
||||||
|
import { Context } from 'hono';
|
||||||
|
|
||||||
|
import { getRelativeUrl, isDomainMatch } from '../src/util.js';
|
||||||
|
|
||||||
|
test('isDomainMatch', () => {
|
||||||
|
// Basic matches
|
||||||
|
expect(isDomainMatch('example.com', 'example.com')).toBe(true);
|
||||||
|
expect(isDomainMatch('sub.example.com', 'example.com')).toBe(true);
|
||||||
|
expect(isDomainMatch('a.example.com', 'b.example.com')).toBe(true);
|
||||||
|
|
||||||
|
// Local hostnames
|
||||||
|
expect(isDomainMatch('romulus', 'romulus')).toBe(true);
|
||||||
|
expect(isDomainMatch('romulus', 'remus')).toBe(false);
|
||||||
|
expect(isDomainMatch('localhost', 'localhost')).toBe(true);
|
||||||
|
expect(isDomainMatch('server', 'server.local')).toBe(false);
|
||||||
|
|
||||||
|
// Two-part TLDs
|
||||||
|
expect(isDomainMatch('example.co.uk', 'example.co.uk')).toBe(true);
|
||||||
|
expect(isDomainMatch('sub.example.co.uk', 'example.co.uk')).toBe(true);
|
||||||
|
expect(isDomainMatch('evil.co.uk', 'bank.co.uk')).toBe(false);
|
||||||
|
expect(isDomainMatch('example.com.au', 'example.com.au')).toBe(true);
|
||||||
|
|
||||||
|
// Attack vectors
|
||||||
|
// Attempt to match on TLD only
|
||||||
|
expect(isDomainMatch('evil.com', 'bank.com')).toBe(false);
|
||||||
|
expect(isDomainMatch('evil.co.uk', 'bank.co.uk')).toBe(false);
|
||||||
|
|
||||||
|
// Subdomain attacks
|
||||||
|
expect(isDomainMatch('evil.com.attacker.com', 'evil.com')).toBe(false);
|
||||||
|
expect(isDomainMatch('bank.co.uk.attacker.com', 'bank.co.uk')).toBe(false);
|
||||||
|
expect(isDomainMatch('example.com.evil.com', 'example.com')).toBe(false);
|
||||||
|
|
||||||
|
// Prefix attacks
|
||||||
|
expect(isDomainMatch('myexample.com', 'example.com')).toBe(false);
|
||||||
|
expect(isDomainMatch('exampleevilsite.com', 'example.com')).toBe(false);
|
||||||
|
|
||||||
|
// Double-dot attacks
|
||||||
|
expect(isDomainMatch('example..com', 'example.com')).toBe(false);
|
||||||
|
expect(isDomainMatch('evil..co..uk', 'bank.co.uk')).toBe(false);
|
||||||
|
|
||||||
|
// Empty parts attacks
|
||||||
|
expect(isDomainMatch('example.com.', 'example.com')).toBe(false);
|
||||||
|
|
||||||
|
// Mixed case attacks
|
||||||
|
expect(isDomainMatch('EXAMPLE.COM', 'example.com')).toBe(false);
|
||||||
|
expect(isDomainMatch('Example.Co.Uk', 'example.co.uk')).toBe(false);
|
||||||
|
|
||||||
|
// IP address attempts
|
||||||
|
expect(isDomainMatch('127.0.0.1', 'localhost')).toBe(false);
|
||||||
|
expect(isDomainMatch('192.168.1.1', '192.168.1.1')).toBe(true);
|
||||||
|
|
||||||
|
// Special character attacks
|
||||||
|
expect(isDomainMatch('exam%70le.com', 'example.com')).toBe(false);
|
||||||
|
expect(isDomainMatch('exam\u0000ple.com', 'example.com')).toBe(false);
|
||||||
|
|
||||||
|
// Unicode/punycode attacks
|
||||||
|
expect(isDomainMatch('xn--e1awd7f.com', 'example.com')).toBe(false);
|
||||||
|
expect(isDomainMatch('еxample.com', 'example.com')).toBe(false); // cyrillic 'е'
|
||||||
|
|
||||||
|
// Edge cases
|
||||||
|
expect(isDomainMatch('', '')).toBe(true); // empty strings
|
||||||
|
expect(isDomainMatch(' ', ' ')).toBe(true); // spaces
|
||||||
|
expect(isDomainMatch('example.com', '')).toBe(false); // empty vs non-empty
|
||||||
|
expect(isDomainMatch('com', 'com')).toBe(true); // single part
|
||||||
|
expect(isDomainMatch('.com', 'com')).toBe(false); // dot prefix
|
||||||
|
|
||||||
|
// Mixed TLD tests
|
||||||
|
expect(isDomainMatch('example.co.uk.com', 'example.co.uk')).toBe(false);
|
||||||
|
expect(isDomainMatch('example.com.co.uk', 'example.co.uk')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getRelativeUrl', () => {
|
||||||
|
// Helper to create a mock Context
|
||||||
|
const createMockContext = (url: string, headers: Record<string, string> = {}) => {
|
||||||
|
return {
|
||||||
|
req: {
|
||||||
|
url,
|
||||||
|
header: (name: string) => headers[name.toLowerCase()] || ''
|
||||||
|
}
|
||||||
|
} as Context;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Test basic URL construction
|
||||||
|
const ctx1 = createMockContext('http://example.com');
|
||||||
|
expect(getRelativeUrl(ctx1, '/path')).toBe('http://example.com/path');
|
||||||
|
|
||||||
|
// Test with x-forwarded headers
|
||||||
|
const ctx2 = createMockContext('http://original.com', {
|
||||||
|
'x-forwarded-host': 'forwarded.com',
|
||||||
|
'x-forwarded-proto': 'https',
|
||||||
|
'x-forwarded-port': '443'
|
||||||
|
});
|
||||||
|
expect(getRelativeUrl(ctx2, '/path')).toBe('https://forwarded.com/path');
|
||||||
|
|
||||||
|
// Test with absolute URLs
|
||||||
|
const ctx4 = createMockContext('http://example.com');
|
||||||
|
expect(getRelativeUrl(ctx4, 'http://other.com/path')).toBe('http://other.com/path');
|
||||||
|
});
|
||||||
13
packages/auth/tsconfig.json
Normal file
13
packages/auth/tsconfig.json
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"extends": "@tsconfig/node22/tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"declaration": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"types": ["node"],
|
||||||
|
"module": "NodeNext",
|
||||||
|
"moduleResolution": "NodeNext",
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"jsxImportSource": "hono/jsx"
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
34
packages/core/.gitignore
vendored
Normal file
34
packages/core/.gitignore
vendored
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
# dependencies (bun install)
|
||||||
|
node_modules
|
||||||
|
|
||||||
|
# output
|
||||||
|
out
|
||||||
|
dist
|
||||||
|
*.tgz
|
||||||
|
|
||||||
|
# code coverage
|
||||||
|
coverage
|
||||||
|
*.lcov
|
||||||
|
|
||||||
|
# logs
|
||||||
|
logs
|
||||||
|
_.log
|
||||||
|
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
|
||||||
|
|
||||||
|
# dotenv environment variable files
|
||||||
|
.env
|
||||||
|
.env.development.local
|
||||||
|
.env.test.local
|
||||||
|
.env.production.local
|
||||||
|
.env.local
|
||||||
|
|
||||||
|
# caches
|
||||||
|
.eslintcache
|
||||||
|
.cache
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# IntelliJ based IDEs
|
||||||
|
.idea
|
||||||
|
|
||||||
|
# Finder (MacOS) folder config
|
||||||
|
.DS_Store
|
||||||
19
packages/core/drizzle.config.ts
Normal file
19
packages/core/drizzle.config.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { defineConfig } from 'drizzle-kit';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
verbose: true,
|
||||||
|
strict: true,
|
||||||
|
out: './migrations',
|
||||||
|
dialect: 'postgresql',
|
||||||
|
schema: './src/**/*.sql.ts',
|
||||||
|
dbCredentials: {
|
||||||
|
host: process.env.DATABASE_URL ? new URL(process.env.DATABASE_URL).hostname : 'localhost',
|
||||||
|
port: process.env.DATABASE_URL ? Number(new URL(process.env.DATABASE_URL).port || 5432) : 5432,
|
||||||
|
user: process.env.DATABASE_URL ? new URL(process.env.DATABASE_URL).username : 'postgres',
|
||||||
|
password: process.env.DATABASE_URL ? new URL(process.env.DATABASE_URL).password : 'postgres',
|
||||||
|
database: process.env.DATABASE_URL
|
||||||
|
? new URL(process.env.DATABASE_URL).pathname.slice(1)
|
||||||
|
: 'nestri',
|
||||||
|
ssl: !!process.env.DATABASE_URL ? { rejectUnauthorized: false } : false
|
||||||
|
}
|
||||||
|
});
|
||||||
57
packages/core/migrations/0000_quick_dark_phoenix.sql
Normal file
57
packages/core/migrations/0000_quick_dark_phoenix.sql
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
CREATE TYPE "public"."linked_account_provider" AS ENUM('owner', 'admin', 'member');--> statement-breakpoint
|
||||||
|
CREATE TABLE "linked_account" (
|
||||||
|
"id" char(30) PRIMARY KEY NOT NULL,
|
||||||
|
"time_created" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"time_updated" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"time_deleted" timestamp with time zone,
|
||||||
|
"user_id" char(30) NOT NULL,
|
||||||
|
"provider" "linked_account_provider" NOT NULL,
|
||||||
|
"provider_account_id" text NOT NULL,
|
||||||
|
"profile" jsonb
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "team_member" (
|
||||||
|
"id" char(30) PRIMARY KEY NOT NULL,
|
||||||
|
"time_created" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"time_updated" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"time_deleted" timestamp with time zone,
|
||||||
|
"team_id" char(30) NOT NULL,
|
||||||
|
"user_id" char(30) NOT NULL,
|
||||||
|
"role" "linked_account_provider" DEFAULT 'member' NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "team" (
|
||||||
|
"id" char(30) PRIMARY KEY NOT NULL,
|
||||||
|
"time_created" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"time_updated" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"time_deleted" timestamp with time zone,
|
||||||
|
"name" text NOT NULL,
|
||||||
|
"slug" text NOT NULL,
|
||||||
|
"owner_id" char(30) NOT NULL,
|
||||||
|
"billing_email" text,
|
||||||
|
"plan" text DEFAULT 'free' NOT NULL,
|
||||||
|
"subscription_status" text DEFAULT 'active' NOT NULL,
|
||||||
|
"metadata" jsonb,
|
||||||
|
CONSTRAINT "team_slug_unique" UNIQUE("slug")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "user" (
|
||||||
|
"id" char(30) PRIMARY KEY NOT NULL,
|
||||||
|
"time_created" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"time_updated" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"time_deleted" timestamp with time zone,
|
||||||
|
"name" text NOT NULL,
|
||||||
|
"email" text,
|
||||||
|
"email_verified" boolean DEFAULT false NOT NULL,
|
||||||
|
"image" text
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "linked_account" ADD CONSTRAINT "linked_account_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "team_member" ADD CONSTRAINT "team_member_team_id_team_id_fk" FOREIGN KEY ("team_id") REFERENCES "public"."team"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "team_member" ADD CONSTRAINT "team_member_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "team" ADD CONSTRAINT "team_owner_id_user_id_fk" FOREIGN KEY ("owner_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "linked_account_provider_unique" ON "linked_account" USING btree ("provider","provider_account_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "linked_account_user_idx" ON "linked_account" USING btree ("user_id");--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "team_member_team_user_unique" ON "team_member" USING btree ("team_id","user_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "team_member_team_idx" ON "team_member" USING btree ("team_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "team_member_user_idx" ON "team_member" USING btree ("user_id");
|
||||||
29
packages/core/migrations/0001_opposite_senator_kelly.sql
Normal file
29
packages/core/migrations/0001_opposite_senator_kelly.sql
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
CREATE TABLE "pairing_code" (
|
||||||
|
"id" char(30) PRIMARY KEY NOT NULL,
|
||||||
|
"time_created" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"time_updated" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"time_deleted" timestamp with time zone,
|
||||||
|
"code" text NOT NULL,
|
||||||
|
"target_user_id" text NOT NULL,
|
||||||
|
"new_fingerprint" text,
|
||||||
|
"expires_at" timestamp with time zone NOT NULL,
|
||||||
|
"claimed_at" timestamp with time zone,
|
||||||
|
"is_claimed" boolean DEFAULT false NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "user_fingerprint" (
|
||||||
|
"id" char(30) PRIMARY KEY NOT NULL,
|
||||||
|
"time_created" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"time_updated" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"time_deleted" timestamp with time zone,
|
||||||
|
"user_id" char(30) NOT NULL,
|
||||||
|
"fingerprint" text NOT NULL,
|
||||||
|
"name" text,
|
||||||
|
"last_seen" timestamp with time zone
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "user_fingerprint" ADD CONSTRAINT "user_fingerprint_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "pairing_code_code_unique" ON "pairing_code" USING btree ("code");--> statement-breakpoint
|
||||||
|
CREATE INDEX "pairing_code_target_user_idx" ON "pairing_code" USING btree ("target_user_id");--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "user_fingerprint_fingerprint_unique" ON "user_fingerprint" USING btree ("fingerprint");--> statement-breakpoint
|
||||||
|
CREATE INDEX "user_fingerprint_user_idx" ON "user_fingerprint" USING btree ("user_id");
|
||||||
102
packages/core/migrations/0002_light_mesmero.sql
Normal file
102
packages/core/migrations/0002_light_mesmero.sql
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
CREATE TYPE "public"."depot_status" AS ENUM('pending', 'downloading', 'complete', 'error', 'deleted');--> statement-breakpoint
|
||||||
|
CREATE TYPE "public"."team_member_role" AS ENUM('owner', 'admin', 'member');--> statement-breakpoint
|
||||||
|
CREATE TYPE "public"."download_status" AS ENUM('pending', 'downloading', 'ready', 'failed', 'cancelled');--> statement-breakpoint
|
||||||
|
CREATE TABLE "game_depot" (
|
||||||
|
"id" char(30) PRIMARY KEY NOT NULL,
|
||||||
|
"time_created" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"time_updated" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"time_deleted" timestamp with time zone,
|
||||||
|
"game_id" char(30) NOT NULL,
|
||||||
|
"depot_id" integer NOT NULL,
|
||||||
|
"branch" text DEFAULT 'public' NOT NULL,
|
||||||
|
"steam_manifest_id" text,
|
||||||
|
"steam_build_id" integer,
|
||||||
|
"installed_manifest_id" text,
|
||||||
|
"installed_build_id" integer,
|
||||||
|
"size_download" bigint,
|
||||||
|
"size_on_disk" bigint,
|
||||||
|
"status" "depot_status" DEFAULT 'pending' NOT NULL,
|
||||||
|
"error_message" text,
|
||||||
|
"oslist" text
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "game" (
|
||||||
|
"id" char(30) PRIMARY KEY NOT NULL,
|
||||||
|
"time_created" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"time_updated" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"time_deleted" timestamp with time zone,
|
||||||
|
"steam_app_id" integer NOT NULL,
|
||||||
|
"slug" text NOT NULL,
|
||||||
|
"name" text NOT NULL,
|
||||||
|
"type" text,
|
||||||
|
"short_description" text,
|
||||||
|
"description" text,
|
||||||
|
"developers" jsonb,
|
||||||
|
"publishers" jsonb,
|
||||||
|
"primary_genre" text,
|
||||||
|
"genres" jsonb,
|
||||||
|
"categories" jsonb,
|
||||||
|
"oslist" jsonb,
|
||||||
|
"size_download" bigint,
|
||||||
|
"size_on_disk" bigint,
|
||||||
|
"controller_support" text,
|
||||||
|
"steam_deck_compat" text,
|
||||||
|
"review_score_percent" smallint,
|
||||||
|
"review_count" integer,
|
||||||
|
"metacritic_score" smallint,
|
||||||
|
"steam_change_number" integer,
|
||||||
|
"public_build_id" integer,
|
||||||
|
"release_date_utc" timestamp with time zone,
|
||||||
|
"time_enriched" timestamp with time zone,
|
||||||
|
CONSTRAINT "game_steam_app_id_unique" UNIQUE("steam_app_id")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "user_download" (
|
||||||
|
"id" char(30) PRIMARY KEY NOT NULL,
|
||||||
|
"time_created" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"time_updated" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"time_deleted" timestamp with time zone,
|
||||||
|
"user_id" char(30) NOT NULL,
|
||||||
|
"game_id" char(30) NOT NULL,
|
||||||
|
"status" "download_status" DEFAULT 'pending' NOT NULL,
|
||||||
|
"progress_bytes" bigint DEFAULT 0,
|
||||||
|
"total_bytes" bigint,
|
||||||
|
"time_started" timestamp with time zone,
|
||||||
|
"time_completed" timestamp with time zone,
|
||||||
|
"error_message" text
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "user_library" (
|
||||||
|
"id" char(30) PRIMARY KEY NOT NULL,
|
||||||
|
"time_created" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"time_updated" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"time_deleted" timestamp with time zone,
|
||||||
|
"user_id" char(30) NOT NULL,
|
||||||
|
"game_id" char(30) NOT NULL,
|
||||||
|
"playtime_2w" integer,
|
||||||
|
"playtime_forever" integer,
|
||||||
|
"last_played" timestamp with time zone
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "linked_account" ALTER COLUMN "provider" SET DATA TYPE text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "team_member" ALTER COLUMN "role" DROP DEFAULT;--> statement-breakpoint
|
||||||
|
ALTER TABLE "team_member" ALTER COLUMN "role" SET DATA TYPE "public"."team_member_role" USING "role"::text::"public"."team_member_role";--> statement-breakpoint
|
||||||
|
ALTER TABLE "team_member" ALTER COLUMN "role" SET DEFAULT 'member';--> statement-breakpoint
|
||||||
|
DROP TYPE "public"."linked_account_provider";--> statement-breakpoint
|
||||||
|
CREATE TYPE "public"."linked_account_provider" AS ENUM('steam', 'ssh', 'discord');--> statement-breakpoint
|
||||||
|
ALTER TABLE "linked_account" ALTER COLUMN "provider" SET DATA TYPE "public"."linked_account_provider" USING "provider"::"public"."linked_account_provider";--> statement-breakpoint
|
||||||
|
ALTER TABLE "game_depot" ADD CONSTRAINT "game_depot_game_id_game_id_fk" FOREIGN KEY ("game_id") REFERENCES "public"."game"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "user_download" ADD CONSTRAINT "user_download_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "user_download" ADD CONSTRAINT "user_download_game_id_game_id_fk" FOREIGN KEY ("game_id") REFERENCES "public"."game"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "user_library" ADD CONSTRAINT "user_library_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "user_library" ADD CONSTRAINT "user_library_game_id_game_id_fk" FOREIGN KEY ("game_id") REFERENCES "public"."game"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "game_depot_unique" ON "game_depot" USING btree ("game_id","depot_id","branch");--> statement-breakpoint
|
||||||
|
CREATE INDEX "game_depot_game_idx" ON "game_depot" USING btree ("game_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "game_depot_updates_idx" ON "game_depot" USING btree ("game_id") WHERE "game_depot"."installed_manifest_id" is distinct from "game_depot"."steam_manifest_id";--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "game_slug_unique" ON "game" USING btree ("slug");--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "game_app_id_unique" ON "game" USING btree ("steam_app_id");--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "user_download_user_game_unique" ON "user_download" USING btree ("user_id","game_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "user_download_user_status_idx" ON "user_download" USING btree ("user_id","status");--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "user_library_user_game_unique" ON "user_library" USING btree ("user_id","game_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "user_library_user_idx" ON "user_library" USING btree ("user_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "user_library_game_idx" ON "user_library" USING btree ("game_id");
|
||||||
2
packages/core/migrations/0003_many_pyro.sql
Normal file
2
packages/core/migrations/0003_many_pyro.sql
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE "game" ADD COLUMN "client_icon" text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "game" ADD COLUMN "icon" text;
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
CREATE TYPE "public"."game_download_status" AS ENUM('pending', 'verifying', 'downloading', 'ready', 'failed');--> statement-breakpoint
|
||||||
|
CREATE TABLE "game_download" (
|
||||||
|
"id" char(30) PRIMARY KEY NOT NULL,
|
||||||
|
"time_created" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"time_updated" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"time_deleted" timestamp with time zone,
|
||||||
|
"host_id" text NOT NULL,
|
||||||
|
"game_id" char(30) NOT NULL,
|
||||||
|
"status" "game_download_status" DEFAULT 'pending' NOT NULL,
|
||||||
|
"progress_bytes" bigint DEFAULT 0,
|
||||||
|
"total_bytes" bigint,
|
||||||
|
"time_started" timestamp with time zone,
|
||||||
|
"time_completed" timestamp with time zone,
|
||||||
|
"error_message" text
|
||||||
|
);--> statement-breakpoint
|
||||||
|
ALTER TABLE "game_download" ADD CONSTRAINT "game_download_game_id_game_id_fk" FOREIGN KEY ("game_id") REFERENCES "public"."game"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "game_download_host_game_unique" ON "game_download" USING btree ("host_id","game_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "game_download_game_idx" ON "game_download" USING btree ("game_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "game_download_host_status_idx" ON "game_download" USING btree ("host_id","status");--> statement-breakpoint
|
||||||
|
DROP TABLE "user_download";--> statement-breakpoint
|
||||||
|
DROP TYPE "public"."download_status";
|
||||||
34
packages/core/migrations/0005_flaky_may_parker.sql
Normal file
34
packages/core/migrations/0005_flaky_may_parker.sql
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
CREATE TABLE "access_token" (
|
||||||
|
"id" char(30) PRIMARY KEY NOT NULL,
|
||||||
|
"time_created" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"time_updated" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"time_deleted" timestamp with time zone,
|
||||||
|
"owner_user_id" char(30) NOT NULL,
|
||||||
|
"team_id" char(30),
|
||||||
|
"name" text NOT NULL,
|
||||||
|
"token_hash" text NOT NULL,
|
||||||
|
"expires_at" timestamp with time zone,
|
||||||
|
"last_used" timestamp with time zone
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "machine" (
|
||||||
|
"id" char(30) PRIMARY KEY NOT NULL,
|
||||||
|
"time_created" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"time_updated" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"time_deleted" timestamp with time zone,
|
||||||
|
"owner_user_id" char(30) NOT NULL,
|
||||||
|
"team_id" char(30),
|
||||||
|
"label" text NOT NULL,
|
||||||
|
"secret_hash" text NOT NULL,
|
||||||
|
"last_seen" timestamp with time zone
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "access_token" ADD CONSTRAINT "access_token_owner_user_id_user_id_fk" FOREIGN KEY ("owner_user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "access_token" ADD CONSTRAINT "access_token_team_id_team_id_fk" FOREIGN KEY ("team_id") REFERENCES "public"."team"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "machine" ADD CONSTRAINT "machine_owner_user_id_user_id_fk" FOREIGN KEY ("owner_user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "access_token_hash_unique" ON "access_token" USING btree ("token_hash");--> statement-breakpoint
|
||||||
|
CREATE INDEX "access_token_owner_idx" ON "access_token" USING btree ("owner_user_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "access_token_team_idx" ON "access_token" USING btree ("team_id");--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "machine_secret_hash_unique" ON "machine" USING btree ("secret_hash");--> statement-breakpoint
|
||||||
|
CREATE INDEX "machine_owner_idx" ON "machine" USING btree ("owner_user_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "machine_team_idx" ON "machine" USING btree ("team_id");
|
||||||
429
packages/core/migrations/meta/0000_snapshot.json
Normal file
429
packages/core/migrations/meta/0000_snapshot.json
Normal file
@@ -0,0 +1,429 @@
|
|||||||
|
{
|
||||||
|
"id": "00b09079-b4e1-4781-848a-abbf2426e174",
|
||||||
|
"prevId": "00000000-0000-0000-0000-000000000000",
|
||||||
|
"version": "7",
|
||||||
|
"dialect": "postgresql",
|
||||||
|
"tables": {
|
||||||
|
"public.linked_account": {
|
||||||
|
"name": "linked_account",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "char(30)",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"time_created": {
|
||||||
|
"name": "time_created",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"time_updated": {
|
||||||
|
"name": "time_updated",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"time_deleted": {
|
||||||
|
"name": "time_deleted",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "char(30)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"provider": {
|
||||||
|
"name": "provider",
|
||||||
|
"type": "linked_account_provider",
|
||||||
|
"typeSchema": "public",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"provider_account_id": {
|
||||||
|
"name": "provider_account_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"profile": {
|
||||||
|
"name": "profile",
|
||||||
|
"type": "jsonb",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"linked_account_provider_unique": {
|
||||||
|
"name": "linked_account_provider_unique",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "provider",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"expression": "provider_account_id",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": true,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
},
|
||||||
|
"linked_account_user_idx": {
|
||||||
|
"name": "linked_account_user_idx",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "user_id",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": false,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"linked_account_user_id_user_id_fk": {
|
||||||
|
"name": "linked_account_user_id_user_id_fk",
|
||||||
|
"tableFrom": "linked_account",
|
||||||
|
"tableTo": "user",
|
||||||
|
"columnsFrom": ["user_id"],
|
||||||
|
"columnsTo": ["id"],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.team_member": {
|
||||||
|
"name": "team_member",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "char(30)",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"time_created": {
|
||||||
|
"name": "time_created",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"time_updated": {
|
||||||
|
"name": "time_updated",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"time_deleted": {
|
||||||
|
"name": "time_deleted",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"team_id": {
|
||||||
|
"name": "team_id",
|
||||||
|
"type": "char(30)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "char(30)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"role": {
|
||||||
|
"name": "role",
|
||||||
|
"type": "linked_account_provider",
|
||||||
|
"typeSchema": "public",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "'member'"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"team_member_team_user_unique": {
|
||||||
|
"name": "team_member_team_user_unique",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "team_id",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"expression": "user_id",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": true,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
},
|
||||||
|
"team_member_team_idx": {
|
||||||
|
"name": "team_member_team_idx",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "team_id",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": false,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
},
|
||||||
|
"team_member_user_idx": {
|
||||||
|
"name": "team_member_user_idx",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "user_id",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": false,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"team_member_team_id_team_id_fk": {
|
||||||
|
"name": "team_member_team_id_team_id_fk",
|
||||||
|
"tableFrom": "team_member",
|
||||||
|
"tableTo": "team",
|
||||||
|
"columnsFrom": ["team_id"],
|
||||||
|
"columnsTo": ["id"],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
},
|
||||||
|
"team_member_user_id_user_id_fk": {
|
||||||
|
"name": "team_member_user_id_user_id_fk",
|
||||||
|
"tableFrom": "team_member",
|
||||||
|
"tableTo": "user",
|
||||||
|
"columnsFrom": ["user_id"],
|
||||||
|
"columnsTo": ["id"],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.team": {
|
||||||
|
"name": "team",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "char(30)",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"time_created": {
|
||||||
|
"name": "time_created",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"time_updated": {
|
||||||
|
"name": "time_updated",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"time_deleted": {
|
||||||
|
"name": "time_deleted",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"slug": {
|
||||||
|
"name": "slug",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"owner_id": {
|
||||||
|
"name": "owner_id",
|
||||||
|
"type": "char(30)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"billing_email": {
|
||||||
|
"name": "billing_email",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"plan": {
|
||||||
|
"name": "plan",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "'free'"
|
||||||
|
},
|
||||||
|
"subscription_status": {
|
||||||
|
"name": "subscription_status",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "'active'"
|
||||||
|
},
|
||||||
|
"metadata": {
|
||||||
|
"name": "metadata",
|
||||||
|
"type": "jsonb",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"team_owner_id_user_id_fk": {
|
||||||
|
"name": "team_owner_id_user_id_fk",
|
||||||
|
"tableFrom": "team",
|
||||||
|
"tableTo": "user",
|
||||||
|
"columnsFrom": ["owner_id"],
|
||||||
|
"columnsTo": ["id"],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {
|
||||||
|
"team_slug_unique": {
|
||||||
|
"name": "team_slug_unique",
|
||||||
|
"nullsNotDistinct": false,
|
||||||
|
"columns": ["slug"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.user": {
|
||||||
|
"name": "user",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "char(30)",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"time_created": {
|
||||||
|
"name": "time_created",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"time_updated": {
|
||||||
|
"name": "time_updated",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"time_deleted": {
|
||||||
|
"name": "time_deleted",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"email": {
|
||||||
|
"name": "email",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"email_verified": {
|
||||||
|
"name": "email_verified",
|
||||||
|
"type": "boolean",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"image": {
|
||||||
|
"name": "image",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"enums": {
|
||||||
|
"public.linked_account_provider": {
|
||||||
|
"name": "linked_account_provider",
|
||||||
|
"schema": "public",
|
||||||
|
"values": ["owner", "admin", "member"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"schemas": {},
|
||||||
|
"sequences": {},
|
||||||
|
"roles": {},
|
||||||
|
"policies": {},
|
||||||
|
"views": {},
|
||||||
|
"_meta": {
|
||||||
|
"columns": {},
|
||||||
|
"schemas": {},
|
||||||
|
"tables": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
640
packages/core/migrations/meta/0001_snapshot.json
Normal file
640
packages/core/migrations/meta/0001_snapshot.json
Normal file
@@ -0,0 +1,640 @@
|
|||||||
|
{
|
||||||
|
"id": "e9c276ac-b054-44b5-ba34-1ea8efe17453",
|
||||||
|
"prevId": "00b09079-b4e1-4781-848a-abbf2426e174",
|
||||||
|
"version": "7",
|
||||||
|
"dialect": "postgresql",
|
||||||
|
"tables": {
|
||||||
|
"public.linked_account": {
|
||||||
|
"name": "linked_account",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "char(30)",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"time_created": {
|
||||||
|
"name": "time_created",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"time_updated": {
|
||||||
|
"name": "time_updated",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"time_deleted": {
|
||||||
|
"name": "time_deleted",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "char(30)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"provider": {
|
||||||
|
"name": "provider",
|
||||||
|
"type": "linked_account_provider",
|
||||||
|
"typeSchema": "public",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"provider_account_id": {
|
||||||
|
"name": "provider_account_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"profile": {
|
||||||
|
"name": "profile",
|
||||||
|
"type": "jsonb",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"linked_account_provider_unique": {
|
||||||
|
"name": "linked_account_provider_unique",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "provider",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"expression": "provider_account_id",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": true,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
},
|
||||||
|
"linked_account_user_idx": {
|
||||||
|
"name": "linked_account_user_idx",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "user_id",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": false,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"linked_account_user_id_user_id_fk": {
|
||||||
|
"name": "linked_account_user_id_user_id_fk",
|
||||||
|
"tableFrom": "linked_account",
|
||||||
|
"tableTo": "user",
|
||||||
|
"columnsFrom": ["user_id"],
|
||||||
|
"columnsTo": ["id"],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.pairing_code": {
|
||||||
|
"name": "pairing_code",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "char(30)",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"time_created": {
|
||||||
|
"name": "time_created",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"time_updated": {
|
||||||
|
"name": "time_updated",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"time_deleted": {
|
||||||
|
"name": "time_deleted",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"code": {
|
||||||
|
"name": "code",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"target_user_id": {
|
||||||
|
"name": "target_user_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"new_fingerprint": {
|
||||||
|
"name": "new_fingerprint",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"expires_at": {
|
||||||
|
"name": "expires_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"claimed_at": {
|
||||||
|
"name": "claimed_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"is_claimed": {
|
||||||
|
"name": "is_claimed",
|
||||||
|
"type": "boolean",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"pairing_code_code_unique": {
|
||||||
|
"name": "pairing_code_code_unique",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "code",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": true,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
},
|
||||||
|
"pairing_code_target_user_idx": {
|
||||||
|
"name": "pairing_code_target_user_idx",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "target_user_id",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": false,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.team_member": {
|
||||||
|
"name": "team_member",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "char(30)",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"time_created": {
|
||||||
|
"name": "time_created",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"time_updated": {
|
||||||
|
"name": "time_updated",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"time_deleted": {
|
||||||
|
"name": "time_deleted",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"team_id": {
|
||||||
|
"name": "team_id",
|
||||||
|
"type": "char(30)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "char(30)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"role": {
|
||||||
|
"name": "role",
|
||||||
|
"type": "linked_account_provider",
|
||||||
|
"typeSchema": "public",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "'member'"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"team_member_team_user_unique": {
|
||||||
|
"name": "team_member_team_user_unique",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "team_id",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"expression": "user_id",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": true,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
},
|
||||||
|
"team_member_team_idx": {
|
||||||
|
"name": "team_member_team_idx",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "team_id",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": false,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
},
|
||||||
|
"team_member_user_idx": {
|
||||||
|
"name": "team_member_user_idx",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "user_id",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": false,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"team_member_team_id_team_id_fk": {
|
||||||
|
"name": "team_member_team_id_team_id_fk",
|
||||||
|
"tableFrom": "team_member",
|
||||||
|
"tableTo": "team",
|
||||||
|
"columnsFrom": ["team_id"],
|
||||||
|
"columnsTo": ["id"],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
},
|
||||||
|
"team_member_user_id_user_id_fk": {
|
||||||
|
"name": "team_member_user_id_user_id_fk",
|
||||||
|
"tableFrom": "team_member",
|
||||||
|
"tableTo": "user",
|
||||||
|
"columnsFrom": ["user_id"],
|
||||||
|
"columnsTo": ["id"],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.team": {
|
||||||
|
"name": "team",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "char(30)",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"time_created": {
|
||||||
|
"name": "time_created",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"time_updated": {
|
||||||
|
"name": "time_updated",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"time_deleted": {
|
||||||
|
"name": "time_deleted",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"slug": {
|
||||||
|
"name": "slug",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"owner_id": {
|
||||||
|
"name": "owner_id",
|
||||||
|
"type": "char(30)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"billing_email": {
|
||||||
|
"name": "billing_email",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"plan": {
|
||||||
|
"name": "plan",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "'free'"
|
||||||
|
},
|
||||||
|
"subscription_status": {
|
||||||
|
"name": "subscription_status",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "'active'"
|
||||||
|
},
|
||||||
|
"metadata": {
|
||||||
|
"name": "metadata",
|
||||||
|
"type": "jsonb",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"team_owner_id_user_id_fk": {
|
||||||
|
"name": "team_owner_id_user_id_fk",
|
||||||
|
"tableFrom": "team",
|
||||||
|
"tableTo": "user",
|
||||||
|
"columnsFrom": ["owner_id"],
|
||||||
|
"columnsTo": ["id"],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {
|
||||||
|
"team_slug_unique": {
|
||||||
|
"name": "team_slug_unique",
|
||||||
|
"nullsNotDistinct": false,
|
||||||
|
"columns": ["slug"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.user_fingerprint": {
|
||||||
|
"name": "user_fingerprint",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "char(30)",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"time_created": {
|
||||||
|
"name": "time_created",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"time_updated": {
|
||||||
|
"name": "time_updated",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"time_deleted": {
|
||||||
|
"name": "time_deleted",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "char(30)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"fingerprint": {
|
||||||
|
"name": "fingerprint",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"last_seen": {
|
||||||
|
"name": "last_seen",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"user_fingerprint_fingerprint_unique": {
|
||||||
|
"name": "user_fingerprint_fingerprint_unique",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "fingerprint",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": true,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
},
|
||||||
|
"user_fingerprint_user_idx": {
|
||||||
|
"name": "user_fingerprint_user_idx",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "user_id",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": false,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"user_fingerprint_user_id_user_id_fk": {
|
||||||
|
"name": "user_fingerprint_user_id_user_id_fk",
|
||||||
|
"tableFrom": "user_fingerprint",
|
||||||
|
"tableTo": "user",
|
||||||
|
"columnsFrom": ["user_id"],
|
||||||
|
"columnsTo": ["id"],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.user": {
|
||||||
|
"name": "user",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "char(30)",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"time_created": {
|
||||||
|
"name": "time_created",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"time_updated": {
|
||||||
|
"name": "time_updated",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"time_deleted": {
|
||||||
|
"name": "time_deleted",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"email": {
|
||||||
|
"name": "email",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"email_verified": {
|
||||||
|
"name": "email_verified",
|
||||||
|
"type": "boolean",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"image": {
|
||||||
|
"name": "image",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"enums": {
|
||||||
|
"public.linked_account_provider": {
|
||||||
|
"name": "linked_account_provider",
|
||||||
|
"schema": "public",
|
||||||
|
"values": ["owner", "admin", "member"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"schemas": {},
|
||||||
|
"sequences": {},
|
||||||
|
"roles": {},
|
||||||
|
"policies": {},
|
||||||
|
"views": {},
|
||||||
|
"_meta": {
|
||||||
|
"columns": {},
|
||||||
|
"schemas": {},
|
||||||
|
"tables": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
1344
packages/core/migrations/meta/0002_snapshot.json
Normal file
1344
packages/core/migrations/meta/0002_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1356
packages/core/migrations/meta/0003_snapshot.json
Normal file
1356
packages/core/migrations/meta/0003_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1362
packages/core/migrations/meta/0004_snapshot.json
Normal file
1362
packages/core/migrations/meta/0004_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user