mirror of
https://github.com/nestriness/nestri.git
synced 2026-09-19 17:25:19 +03:00
feat: Sync to OSS repo
This commit is contained in:
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
1699
packages/core/migrations/meta/0005_snapshot.json
Normal file
1699
packages/core/migrations/meta/0005_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
48
packages/core/migrations/meta/_journal.json
Normal file
48
packages/core/migrations/meta/_journal.json
Normal file
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "7",
|
||||
"when": 1784801002476,
|
||||
"tag": "0000_quick_dark_phoenix",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"version": "7",
|
||||
"when": 1785312635128,
|
||||
"tag": "0001_opposite_senator_kelly",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 2,
|
||||
"version": "7",
|
||||
"when": 1785379712946,
|
||||
"tag": "0002_light_mesmero",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 3,
|
||||
"version": "7",
|
||||
"when": 1785382013687,
|
||||
"tag": "0003_many_pyro",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 4,
|
||||
"version": "7",
|
||||
"when": 1785588097470,
|
||||
"tag": "0004_remove_user_download_add_game_download",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 5,
|
||||
"version": "7",
|
||||
"when": 1785909838801,
|
||||
"tag": "0005_flaky_may_parker",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
34
packages/core/package.json
Normal file
34
packages/core/package.json
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"name": "@nestri/core",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
"./*": {
|
||||
"types": "./src/*.ts",
|
||||
"import": "./src/*.ts"
|
||||
},
|
||||
"./**/*": {
|
||||
"types": "./src/**/*.ts",
|
||||
"import": "./src/**/*.ts"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"db": "drizzle-kit",
|
||||
"db:push": "drizzle-kit push"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nestri/auth": "workspace:",
|
||||
"drizzle-orm": "^0.45.2",
|
||||
"postgres": "^3.4.9",
|
||||
"postgresql": "^0.0.1",
|
||||
"zod": "catalog:",
|
||||
"zod-openapi": "^6.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"drizzle-kit": "^0.31.10"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "catalog:"
|
||||
}
|
||||
}
|
||||
39
packages/core/src/access-token/access-token.sql.ts
Normal file
39
packages/core/src/access-token/access-token.sql.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { index, pgTable, text, uniqueIndex } from 'drizzle-orm/pg-core';
|
||||
|
||||
import { id, timestamps, ulid, utc } from '../db/types.js';
|
||||
import { TeamTable } from '../team/team.sql.js';
|
||||
import { UserTable } from '../user/user.sql.js';
|
||||
|
||||
/**
|
||||
* A personal access token: a long-lived, revocable credential a user creates
|
||||
* for something that is not a browser — a nessh box registering itself, or a
|
||||
* script driving the API.
|
||||
*
|
||||
* Deliberately not a session JWT. A JWT is short-lived and cannot be revoked
|
||||
* without rotating signing keys for everyone, which makes it wrong for a
|
||||
* credential that sits in a config file on a machine for months.
|
||||
*/
|
||||
export const AccessTokenTable = pgTable(
|
||||
'access_token',
|
||||
{
|
||||
...id,
|
||||
...timestamps,
|
||||
ownerUserId: ulid('owner_user_id')
|
||||
.notNull()
|
||||
.references(() => UserTable.id, { onDelete: 'cascade' }),
|
||||
// Set to act within a team rather than as the user alone. The grant is
|
||||
// re-checked against live membership on every use, so losing the
|
||||
// membership disables the token without anyone remembering to revoke it.
|
||||
teamId: ulid('team_id').references(() => TeamTable.id, { onDelete: 'cascade' }),
|
||||
name: text('name').notNull(),
|
||||
// Only the digest. The token is shown once, at creation.
|
||||
tokenHash: text('token_hash').notNull(),
|
||||
expiresAt: utc('expires_at'),
|
||||
lastUsed: utc('last_used')
|
||||
},
|
||||
(t) => [
|
||||
uniqueIndex('access_token_hash_unique').on(t.tokenHash),
|
||||
index('access_token_owner_idx').on(t.ownerUserId),
|
||||
index('access_token_team_idx').on(t.teamId)
|
||||
]
|
||||
);
|
||||
185
packages/core/src/access-token/index.ts
Normal file
185
packages/core/src/access-token/index.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
import { randomBytes } from 'node:crypto';
|
||||
|
||||
import { and, eq, isNull, sql } from 'drizzle-orm';
|
||||
import z from 'zod';
|
||||
|
||||
import { Database } from '../db/index.js';
|
||||
import { Examples } from '../examples.js';
|
||||
import { fn } from '../fn.js';
|
||||
import { AccessTokenTable } from './access-token.sql.js';
|
||||
|
||||
/**
|
||||
* Personal access tokens.
|
||||
*
|
||||
* The prefix is load-bearing: the API decides how to verify a bearer token by
|
||||
* looking at it, so a PAT never reaches JWT verification and a JWT never
|
||||
* reaches a database lookup. Without it every request would pay for both.
|
||||
*/
|
||||
export namespace AccessToken {
|
||||
export const PREFIX = 'pat_';
|
||||
|
||||
const SECRET_BYTES = 32;
|
||||
|
||||
export const Info = z
|
||||
.object({
|
||||
id: z.string().meta({
|
||||
description: 'Unique identifier for the token record',
|
||||
example: Examples.AccessToken.id
|
||||
}),
|
||||
ownerUserId: z.string().meta({
|
||||
description: 'The user this token acts as',
|
||||
example: Examples.AccessToken.ownerUserId
|
||||
}),
|
||||
teamId: z.string().optional().nullable().meta({
|
||||
description: 'Team this token acts within, when it is team-scoped',
|
||||
example: Examples.AccessToken.teamId
|
||||
}),
|
||||
name: z.string().meta({
|
||||
description: 'What the token is for, so it can be recognised later',
|
||||
example: Examples.AccessToken.name
|
||||
}),
|
||||
expiresAt: z.iso.datetime().optional().nullable().meta({
|
||||
description: 'When the token stops working. Null means it does not expire.',
|
||||
example: Examples.AccessToken.expiresAt
|
||||
}),
|
||||
lastUsed: z.iso.datetime().optional().nullable().meta({
|
||||
description: 'When the token was last accepted',
|
||||
example: Examples.AccessToken.lastUsed
|
||||
})
|
||||
})
|
||||
.meta({
|
||||
ref: 'AccessToken',
|
||||
description: 'A long-lived, revocable credential for non-browser access',
|
||||
example: Examples.AccessToken
|
||||
});
|
||||
|
||||
export type Info = z.infer<typeof Info>;
|
||||
|
||||
function generateToken(): string {
|
||||
return `${PREFIX}${randomBytes(SECRET_BYTES).toString('base64url')}`;
|
||||
}
|
||||
|
||||
async function hashToken(token: string): Promise<string> {
|
||||
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(token));
|
||||
return Array.from(new Uint8Array(digest))
|
||||
.map((b) => b.toString(16).padStart(2, '0'))
|
||||
.join('');
|
||||
}
|
||||
|
||||
/** Cheap check that routes a bearer token to the right verifier. */
|
||||
export function looksLikeToken(bearer: string): boolean {
|
||||
return bearer.startsWith(PREFIX);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mint a token. The value is returned here and nowhere else — only its
|
||||
* digest is stored, so a lost token is replaced rather than recovered.
|
||||
*/
|
||||
export const create = fn(
|
||||
Info.pick({ id: true, ownerUserId: true, teamId: true, name: true }).extend({
|
||||
expiresInDays: z.number().int().min(1).optional()
|
||||
}),
|
||||
async (input) => {
|
||||
const token = generateToken();
|
||||
await Database.use(async (tx) => {
|
||||
await tx.insert(AccessTokenTable).values({
|
||||
id: input.id,
|
||||
ownerUserId: input.ownerUserId,
|
||||
teamId: input.teamId ?? null,
|
||||
name: input.name,
|
||||
tokenHash: await hashToken(token),
|
||||
expiresAt: input.expiresInDays
|
||||
? sql`now() + interval '${sql.raw(String(input.expiresInDays))} days'`
|
||||
: null,
|
||||
lastUsed: null
|
||||
});
|
||||
});
|
||||
return { id: input.id, token };
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Resolve a token to its record, or `null`.
|
||||
*
|
||||
* Expiry is part of the query rather than a check afterwards: an expired
|
||||
* token and an unknown one are then indistinguishable to the caller, and
|
||||
* there is no branch left where a stale row could be accepted by mistake.
|
||||
*/
|
||||
export const authenticate = fn(z.string(), async (token) => {
|
||||
if (!looksLikeToken(token)) {
|
||||
return null;
|
||||
}
|
||||
const tokenHash = await hashToken(token);
|
||||
return Database.use(async (tx) => {
|
||||
return tx
|
||||
.select()
|
||||
.from(AccessTokenTable)
|
||||
.where(
|
||||
and(
|
||||
eq(AccessTokenTable.tokenHash, tokenHash),
|
||||
isNull(AccessTokenTable.timeDeleted),
|
||||
sql`(${AccessTokenTable.expiresAt} is null or ${AccessTokenTable.expiresAt} > now())`
|
||||
)
|
||||
)
|
||||
.then((rows) => {
|
||||
const row = rows.at(0);
|
||||
// Serialized here so `tokenHash` never leaves this function.
|
||||
return row ? serialize(row) : null;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
export const touchLastUsed = fn(Info.shape.id, async (id) => {
|
||||
await Database.use(async (tx) => {
|
||||
await tx
|
||||
.update(AccessTokenTable)
|
||||
.set({ lastUsed: sql`now()` })
|
||||
.where(eq(AccessTokenTable.id, id));
|
||||
});
|
||||
});
|
||||
|
||||
export const listByOwner = fn(Info.shape.ownerUserId, async (ownerUserId) => {
|
||||
return Database.use(async (tx) => {
|
||||
return tx
|
||||
.select()
|
||||
.from(AccessTokenTable)
|
||||
.where(
|
||||
and(eq(AccessTokenTable.ownerUserId, ownerUserId), isNull(AccessTokenTable.timeDeleted))
|
||||
)
|
||||
.orderBy(AccessTokenTable.timeCreated)
|
||||
.then((rows) => rows.map(serialize));
|
||||
});
|
||||
});
|
||||
|
||||
/** Revoke by id, but only for its owner — ids are guessable in shape. */
|
||||
export const revoke = fn(Info.pick({ id: true, ownerUserId: true }), async (input) => {
|
||||
return Database.use(async (tx) => {
|
||||
return tx
|
||||
.update(AccessTokenTable)
|
||||
.set({ timeDeleted: sql`now()` })
|
||||
.where(
|
||||
and(
|
||||
eq(AccessTokenTable.id, input.id),
|
||||
eq(AccessTokenTable.ownerUserId, input.ownerUserId),
|
||||
isNull(AccessTokenTable.timeDeleted)
|
||||
)
|
||||
)
|
||||
.returning()
|
||||
.then((rows) => {
|
||||
const row = rows.at(0);
|
||||
return row ? serialize(row) : null;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
export function serialize(input: typeof AccessTokenTable.$inferSelect): z.infer<typeof Info> {
|
||||
return {
|
||||
id: input.id,
|
||||
ownerUserId: input.ownerUserId,
|
||||
teamId: input.teamId,
|
||||
name: input.name,
|
||||
expiresAt: input.expiresAt?.toISOString() ?? null,
|
||||
lastUsed: input.lastUsed?.toISOString() ?? null
|
||||
};
|
||||
}
|
||||
}
|
||||
180
packages/core/src/actor.ts
Normal file
180
packages/core/src/actor.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { Context } from './context.js';
|
||||
import { ErrorCodes, VisibleError } from './error.js';
|
||||
|
||||
const Public = z.object({
|
||||
type: z.literal('public'),
|
||||
properties: z.object({})
|
||||
});
|
||||
|
||||
const User = z.object({
|
||||
type: z.literal('user'),
|
||||
properties: z.object({
|
||||
userID: z.string(),
|
||||
linkedAccountID: z.string(),
|
||||
fingerprint: z.string().optional()
|
||||
})
|
||||
});
|
||||
|
||||
const Member = z.object({
|
||||
type: z.literal('member'),
|
||||
properties: z.object({
|
||||
userID: z.string(),
|
||||
teamID: z.string(),
|
||||
role: z.enum(['owner', 'admin', 'member'])
|
||||
})
|
||||
});
|
||||
|
||||
const System = z.object({
|
||||
type: z.literal('system'),
|
||||
properties: z.object({
|
||||
teamID: z.string()
|
||||
})
|
||||
});
|
||||
|
||||
const Admin = z.object({
|
||||
type: z.literal('admin'),
|
||||
properties: z.object({})
|
||||
});
|
||||
|
||||
/**
|
||||
* A registered nessh host, authenticated by its own credentials.
|
||||
*
|
||||
* Deliberately not a `user`: the box acts on behalf of whoever is logged into
|
||||
* it, which is not the same authority as its owner. It carries `ownerUserID`
|
||||
* for attribution, but `Actor.userID` refuses it, so a route written for a
|
||||
* signed-in human cannot silently accept a box instead.
|
||||
*/
|
||||
const Machine = z.object({
|
||||
type: z.literal('machine'),
|
||||
properties: z.object({
|
||||
machineID: z.string(),
|
||||
ownerUserID: z.string(),
|
||||
teamID: z.string().optional()
|
||||
})
|
||||
});
|
||||
|
||||
const ActorInfo = z.discriminatedUnion('type', [Public, User, Member, System, Admin, Machine]);
|
||||
type ActorInfo = z.infer<typeof ActorInfo>;
|
||||
|
||||
const _context = Context.create<ActorInfo>();
|
||||
|
||||
function _use(): ActorInfo {
|
||||
return _context.use();
|
||||
}
|
||||
|
||||
function _with<T>(value: ActorInfo, fn: () => T): T {
|
||||
return _context.provide(value, fn);
|
||||
}
|
||||
|
||||
function _assert<T extends ActorInfo['type']>(type: T): Extract<ActorInfo, { type: T }> {
|
||||
const actor = _use();
|
||||
if (actor.type !== type) {
|
||||
throw new VisibleError(
|
||||
'internal',
|
||||
ErrorCodes.Server.INTERNAL_ERROR,
|
||||
`Expected actor type ${type}, got ${actor.type}`
|
||||
);
|
||||
}
|
||||
return actor as Extract<ActorInfo, { type: T }>;
|
||||
}
|
||||
|
||||
export const Actor = {
|
||||
Info: ActorInfo,
|
||||
|
||||
use: _use,
|
||||
with: _with,
|
||||
assert: _assert,
|
||||
|
||||
get type(): ActorInfo['type'] {
|
||||
return _use().type;
|
||||
},
|
||||
|
||||
get userID(): string {
|
||||
const actor = _use();
|
||||
if (actor.type === 'user' || actor.type === 'member') {
|
||||
return actor.properties.userID;
|
||||
}
|
||||
if (actor.type === 'machine') {
|
||||
// A box holds credentials but is not its owner. Refusing here is
|
||||
// what keeps a route written for a human from accepting a box —
|
||||
// and it is a caller error, not a server fault, so it must not
|
||||
// surface as a 500.
|
||||
throw new VisibleError(
|
||||
'forbidden',
|
||||
ErrorCodes.Permission.INSUFFICIENT_PERMISSIONS,
|
||||
'A machine cannot act as its owner; this route requires a user session'
|
||||
);
|
||||
}
|
||||
throw new VisibleError(
|
||||
'internal',
|
||||
ErrorCodes.Server.INTERNAL_ERROR,
|
||||
`Actor type ${actor.type} has no userID`
|
||||
);
|
||||
},
|
||||
|
||||
get machineID(): string {
|
||||
const actor = _use();
|
||||
if (actor.type === 'machine') {
|
||||
return actor.properties.machineID;
|
||||
}
|
||||
throw new VisibleError(
|
||||
'internal',
|
||||
ErrorCodes.Server.INTERNAL_ERROR,
|
||||
`Actor type ${actor.type} has no machineID`
|
||||
);
|
||||
},
|
||||
|
||||
get linkedAccountID(): string {
|
||||
const actor = _use();
|
||||
if (actor.type === 'user') {
|
||||
return actor.properties.linkedAccountID;
|
||||
}
|
||||
throw new VisibleError(
|
||||
'internal',
|
||||
ErrorCodes.Server.INTERNAL_ERROR,
|
||||
`Actor type ${actor.type} has no linkedAccountID`
|
||||
);
|
||||
},
|
||||
|
||||
get fingerprint(): string | undefined {
|
||||
const actor = _use();
|
||||
if (actor.type === 'user') {
|
||||
return actor.properties.fingerprint;
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
|
||||
get useTeam(): string {
|
||||
const actor = _use();
|
||||
if (actor.type === 'member' || actor.type === 'system') {
|
||||
return actor.properties.teamID;
|
||||
}
|
||||
throw new VisibleError(
|
||||
'internal',
|
||||
ErrorCodes.Server.INTERNAL_ERROR,
|
||||
`Actor type ${actor.type} has no team scope`
|
||||
);
|
||||
},
|
||||
|
||||
get role(): 'owner' | 'admin' | 'member' {
|
||||
const actor = _use();
|
||||
if (actor.type === 'member') {
|
||||
return actor.properties.role;
|
||||
}
|
||||
throw new VisibleError(
|
||||
'internal',
|
||||
ErrorCodes.Server.INTERNAL_ERROR,
|
||||
`Actor type ${actor.type} has no role`
|
||||
);
|
||||
},
|
||||
|
||||
get isSignedIn(): boolean {
|
||||
try {
|
||||
return _use().type !== 'public';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
};
|
||||
10
packages/core/src/auth/subjects.ts
Normal file
10
packages/core/src/auth/subjects.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { createSubjects } from '@nestri/auth/subject';
|
||||
import { z } from 'zod';
|
||||
|
||||
export const subjects = createSubjects({
|
||||
user: z.object({
|
||||
userID: z.string(),
|
||||
linkedAccountID: z.string(),
|
||||
fingerprint: z.string().optional()
|
||||
})
|
||||
});
|
||||
31
packages/core/src/context.ts
Normal file
31
packages/core/src/context.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { AsyncLocalStorage } from 'node:async_hooks';
|
||||
|
||||
import { ErrorCodes, VisibleError } from './error.js';
|
||||
|
||||
export namespace Context {
|
||||
export class NotFound extends VisibleError {
|
||||
constructor() {
|
||||
super(
|
||||
'internal',
|
||||
ErrorCodes.Server.INTERNAL_ERROR,
|
||||
'No context available - actor-dependent code was called outside of Actor.with()'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function create<T>() {
|
||||
const storage = new AsyncLocalStorage<T>();
|
||||
return {
|
||||
use() {
|
||||
const result = storage.getStore();
|
||||
if (!result) {
|
||||
throw new NotFound();
|
||||
}
|
||||
return result;
|
||||
},
|
||||
provide<R>(value: T, fn: () => R) {
|
||||
return storage.run<R>(value, fn);
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
114
packages/core/src/db/index.ts
Normal file
114
packages/core/src/db/index.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { type ExtractTablesWithRelations } from 'drizzle-orm';
|
||||
import { PgTransaction, type PgTransactionConfig } from 'drizzle-orm/pg-core';
|
||||
import { drizzle } from 'drizzle-orm/postgres-js';
|
||||
import { type PostgresJsQueryResultHKT } from 'drizzle-orm/postgres-js';
|
||||
import postgres from 'postgres';
|
||||
|
||||
import { Context } from '../context.js';
|
||||
import { Env } from '../env.js';
|
||||
|
||||
export namespace Database {
|
||||
export async function ping() {
|
||||
const url = Env.get().DATABASE_URL || process.env.DATABASE_URL;
|
||||
const sql = url
|
||||
? postgres(url, { idle_timeout: 30, connect_timeout: 30 })
|
||||
: postgres({
|
||||
idle_timeout: 30,
|
||||
connect_timeout: 30,
|
||||
host: 'localhost',
|
||||
database: 'nestri',
|
||||
user: 'postgres',
|
||||
password: 'postgres',
|
||||
port: 5432
|
||||
});
|
||||
try {
|
||||
const [result] = await sql`SELECT 1`;
|
||||
return result ? true : false;
|
||||
} finally {
|
||||
await sql.end();
|
||||
}
|
||||
}
|
||||
|
||||
export function client() {
|
||||
const url = Env.get().DATABASE_URL || process.env.DATABASE_URL;
|
||||
const c = url
|
||||
? postgres(url, { idle_timeout: 30, connect_timeout: 30 })
|
||||
: postgres({
|
||||
idle_timeout: 30,
|
||||
connect_timeout: 30,
|
||||
host: 'localhost',
|
||||
database: 'nestri',
|
||||
user: 'postgres',
|
||||
password: 'postgres',
|
||||
port: 5432
|
||||
});
|
||||
return drizzle({ client: c });
|
||||
}
|
||||
|
||||
export type Transaction = PgTransaction<
|
||||
PostgresJsQueryResultHKT,
|
||||
Record<string, never>,
|
||||
ExtractTablesWithRelations<Record<string, never>>
|
||||
>;
|
||||
|
||||
export type TxOrDb = Transaction | ReturnType<typeof client>;
|
||||
|
||||
const TransactionContext = Context.create<{
|
||||
tx: TxOrDb;
|
||||
effects: (() => void | Promise<void>)[];
|
||||
}>();
|
||||
|
||||
export async function use<T>(callback: (trx: TxOrDb) => Promise<T>) {
|
||||
try {
|
||||
const { tx } = TransactionContext.use();
|
||||
return tx.transaction(callback);
|
||||
} catch (err) {
|
||||
if (err instanceof Context.NotFound) {
|
||||
const effects: (() => void | Promise<void>)[] = [];
|
||||
const result = await TransactionContext.provide(
|
||||
{
|
||||
effects,
|
||||
tx: client()
|
||||
},
|
||||
() => callback(client())
|
||||
);
|
||||
await Promise.all(effects.map((x) => x()));
|
||||
return result;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export async function fn<Input, T>(callback: (input: Input, trx: TxOrDb) => Promise<T>) {
|
||||
return (input: Input) => use(async (tx) => callback(input, tx));
|
||||
}
|
||||
|
||||
export async function effect(effect: () => any | Promise<any>) {
|
||||
try {
|
||||
const { effects } = TransactionContext.use();
|
||||
effects.push(effect);
|
||||
} catch {
|
||||
await effect();
|
||||
}
|
||||
}
|
||||
|
||||
export async function transaction<T>(
|
||||
callback: (tx: TxOrDb) => Promise<T>,
|
||||
config?: PgTransactionConfig
|
||||
) {
|
||||
try {
|
||||
const { tx } = TransactionContext.use();
|
||||
return callback(tx);
|
||||
} catch (err) {
|
||||
if (err instanceof Context.NotFound) {
|
||||
const effects: (() => void | Promise<void>)[] = [];
|
||||
const result = await client().transaction(async (tx) => {
|
||||
return TransactionContext.provide({ tx, effects }, () => callback(tx));
|
||||
}, config);
|
||||
await Promise.all(effects.map((x) => x()));
|
||||
return result;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
22
packages/core/src/db/test.ts
Normal file
22
packages/core/src/db/test.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import postgres from 'postgres';
|
||||
|
||||
/**
|
||||
* Fail-closed test database connection.
|
||||
*
|
||||
* Tests must never silently fall back to an ad-hoc localhost database, so
|
||||
* this throws unless an explicit `TEST_DATABASE_URL` is set. Use an isolated
|
||||
* database for tests, e.g.:
|
||||
*
|
||||
* TEST_DATABASE_URL=postgres://postgres:postgres@localhost:5432/nestri
|
||||
*/
|
||||
export function testDb() {
|
||||
const url = process.env.TEST_DATABASE_URL;
|
||||
if (!url) {
|
||||
throw new Error(
|
||||
'TEST_DATABASE_URL is not set; refusing to run against an unspecified database. ' +
|
||||
'Set it to an isolated test database, e.g. ' +
|
||||
'TEST_DATABASE_URL=postgres://postgres:postgres@localhost:5432/nestri'
|
||||
);
|
||||
}
|
||||
return postgres(url, { idle_timeout: 30, connect_timeout: 30 });
|
||||
}
|
||||
23
packages/core/src/db/types.ts
Normal file
23
packages/core/src/db/types.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { char, timestamp as rawTs } from 'drizzle-orm/pg-core';
|
||||
|
||||
export const ulid = (name: string) => char(name, { length: 26 + 4 });
|
||||
|
||||
export const id = {
|
||||
get id() {
|
||||
return ulid('id').primaryKey().notNull();
|
||||
}
|
||||
};
|
||||
|
||||
export const utc = (name: string) =>
|
||||
rawTs(name, {
|
||||
withTimezone: true
|
||||
});
|
||||
|
||||
export const timestamps = {
|
||||
timeCreated: utc('time_created').notNull().defaultNow(),
|
||||
timeUpdated: utc('time_updated')
|
||||
.notNull()
|
||||
.defaultNow()
|
||||
.$onUpdate(() => new Date()),
|
||||
timeDeleted: utc('time_deleted')
|
||||
};
|
||||
41
packages/core/src/env.ts
Normal file
41
packages/core/src/env.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { memo } from './utils/memo.js';
|
||||
|
||||
let _overrides: Record<string, unknown> = {};
|
||||
|
||||
export namespace Env {
|
||||
export const Info = z.object({
|
||||
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
|
||||
|
||||
STEAM_API_KEY: z.string().optional(),
|
||||
|
||||
AUTH_ISSUER_URL: z.string().optional(),
|
||||
|
||||
SSH_AUTH_KEY: z.string().optional(),
|
||||
|
||||
ADMIN_SHARED_SECRET: z.string().optional(),
|
||||
|
||||
DATABASE_URL: z.string().optional()
|
||||
});
|
||||
|
||||
export type Info = z.infer<typeof Info>;
|
||||
|
||||
const _get = memo(() => Info.parse({ ...process.env, ..._overrides }));
|
||||
|
||||
export function get(): Info {
|
||||
return _get();
|
||||
}
|
||||
|
||||
export function init(bindings: Record<string, unknown>) {
|
||||
_overrides = {
|
||||
...bindings,
|
||||
...(bindings.HYPERDRIVE
|
||||
? {
|
||||
DATABASE_URL: (bindings.HYPERDRIVE as { connectionString: string }).connectionString
|
||||
}
|
||||
: {})
|
||||
};
|
||||
_get.reset();
|
||||
}
|
||||
}
|
||||
124
packages/core/src/error.ts
Normal file
124
packages/core/src/error.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const ErrorResponse = z
|
||||
.object({
|
||||
type: z
|
||||
.enum([
|
||||
'validation',
|
||||
'authentication',
|
||||
'forbidden',
|
||||
'not_found',
|
||||
'already_exists',
|
||||
'rate_limit',
|
||||
'internal'
|
||||
])
|
||||
.meta({
|
||||
description: 'The error type category',
|
||||
examples: ['validation', 'authentication']
|
||||
}),
|
||||
code: z.string().meta({
|
||||
description: 'Machine-readable error code identifier',
|
||||
examples: ['invalid_parameter', 'missing_required_field', 'unauthorized']
|
||||
}),
|
||||
message: z.string().meta({
|
||||
description: 'Human-readable error message',
|
||||
examples: ['The request was invalid', 'Authentication required']
|
||||
}),
|
||||
param: z
|
||||
.string()
|
||||
.optional()
|
||||
.meta({
|
||||
description: 'The parameter that caused the error (if applicable)',
|
||||
examples: ['email', 'user_id', 'team_id']
|
||||
}),
|
||||
details: z.any().optional().meta({
|
||||
description: 'Additional error context information'
|
||||
})
|
||||
})
|
||||
.meta({ ref: 'ErrorResponse' });
|
||||
|
||||
export type ErrorResponseType = z.infer<typeof ErrorResponse>;
|
||||
|
||||
export const ErrorCodes = {
|
||||
Validation: {
|
||||
MISSING_REQUIRED_FIELD: 'missing_required_field',
|
||||
ALREADY_EXISTS: 'resource_already_exists',
|
||||
TEAM_ALREADY_EXISTS: 'team_already_exists',
|
||||
INVALID_PARAMETER: 'invalid_parameter',
|
||||
INVALID_FORMAT: 'invalid_format',
|
||||
INVALID_STATE: 'invalid_state',
|
||||
IN_USE: 'resource_in_use'
|
||||
},
|
||||
|
||||
Authentication: {
|
||||
UNAUTHORIZED: 'unauthorized',
|
||||
INVALID_TOKEN: 'invalid_token',
|
||||
EXPIRED_TOKEN: 'expired_token',
|
||||
INVALID_CREDENTIALS: 'invalid_credentials'
|
||||
},
|
||||
|
||||
Permission: {
|
||||
FORBIDDEN: 'forbidden',
|
||||
INSUFFICIENT_PERMISSIONS: 'insufficient_permissions',
|
||||
ACCOUNT_RESTRICTED: 'account_restricted'
|
||||
},
|
||||
|
||||
NotFound: {
|
||||
RESOURCE_NOT_FOUND: 'resource_not_found'
|
||||
},
|
||||
|
||||
RateLimit: {
|
||||
TOO_MANY_REQUESTS: 'too_many_requests',
|
||||
QUOTA_EXCEEDED: 'quota_exceeded'
|
||||
},
|
||||
|
||||
Server: {
|
||||
INTERNAL_ERROR: 'internal_error',
|
||||
SERVICE_UNAVAILABLE: 'service_unavailable',
|
||||
DEPENDENCY_FAILURE: 'dependency_failure'
|
||||
}
|
||||
};
|
||||
|
||||
export class VisibleError extends Error {
|
||||
constructor(
|
||||
public type: ErrorResponseType['type'],
|
||||
public code: string,
|
||||
public override message: string,
|
||||
public param?: string,
|
||||
public details?: any
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
public statusCode(): number {
|
||||
switch (this.type) {
|
||||
case 'validation':
|
||||
return 400;
|
||||
case 'authentication':
|
||||
return 401;
|
||||
case 'forbidden':
|
||||
return 403;
|
||||
case 'not_found':
|
||||
return 404;
|
||||
case 'already_exists':
|
||||
return 409;
|
||||
case 'rate_limit':
|
||||
return 429;
|
||||
case 'internal':
|
||||
return 500;
|
||||
}
|
||||
}
|
||||
|
||||
public toResponse(): ErrorResponseType {
|
||||
const response: ErrorResponseType = {
|
||||
type: this.type,
|
||||
code: this.code,
|
||||
message: this.message
|
||||
};
|
||||
|
||||
if (this.param) response.param = this.param;
|
||||
if (this.details) response.details = this.details;
|
||||
|
||||
return response;
|
||||
}
|
||||
}
|
||||
141
packages/core/src/examples.ts
Normal file
141
packages/core/src/examples.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import { Identifier } from './id.js';
|
||||
|
||||
export namespace Examples {
|
||||
export const Id = (prefix: keyof typeof Identifier.prefixes) =>
|
||||
`${Identifier.prefixes[prefix]}_XXXXXXXXXXXXXXXXXXXXXXXXX`;
|
||||
|
||||
export const User = {
|
||||
id: Id('user'),
|
||||
name: 'John Doe',
|
||||
email: 'johndoe@example.com',
|
||||
emailVerified: true,
|
||||
image: 'https://cdn.discordapp.com/avatars/xxxxxxx/xxxxxxx.png'
|
||||
};
|
||||
|
||||
export const LinkedAccount = {
|
||||
id: Id('linkedAccount'),
|
||||
userId: Id('user'),
|
||||
provider: 'steam',
|
||||
providerAccountId: '76561197960287930',
|
||||
profile: { personaname: 'John Doe', avatarfull: 'https://avatars.steamstatic.com/xxxx.jpg' }
|
||||
};
|
||||
|
||||
export const Team = {
|
||||
id: Id('team'),
|
||||
name: 'The A Team',
|
||||
slug: 'the-a-team',
|
||||
ownerId: Id('user'),
|
||||
billingEmail: 'billing@example.com',
|
||||
plan: 'free',
|
||||
subscriptionStatus: 'active',
|
||||
metadata: null
|
||||
};
|
||||
|
||||
export const Member = {
|
||||
id: Id('teamMember'),
|
||||
teamId: Id('team'),
|
||||
userId: Id('user'),
|
||||
role: 'owner' as const
|
||||
};
|
||||
|
||||
export const Fingerprint = {
|
||||
id: Id('userFingerprint'),
|
||||
userId: Id('user'),
|
||||
fingerprint: 'a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4',
|
||||
name: 'MacBook Air',
|
||||
lastSeen: '2026-07-28T12:00:00.000Z'
|
||||
};
|
||||
|
||||
export const PairingCode = {
|
||||
id: Id('pairingCode'),
|
||||
code: 'NESSH-7F2Q',
|
||||
targetUserId: Id('user'),
|
||||
newFingerprint: null,
|
||||
expiresAt: '2026-07-28T12:10:00.000Z',
|
||||
claimedAt: null,
|
||||
isClaimed: false
|
||||
};
|
||||
|
||||
export const Game = {
|
||||
id: Id('game'),
|
||||
steamAppId: 730,
|
||||
slug: 'counter-strike-2',
|
||||
name: 'Counter-Strike 2',
|
||||
type: 'game',
|
||||
clientIcon: '5aad412d01a9b91ba0379f0b35f4eb0b69d9db08',
|
||||
icon: 'f92b09dab91f1d1738f72fe0dd9be18dcc2901f9',
|
||||
shortDescription: 'For 25 years...',
|
||||
description: 'For over two decades...',
|
||||
developers: ['Valve'],
|
||||
publishers: ['Valve'],
|
||||
primaryGenre: 'Action',
|
||||
genres: ['Action', 'FPS'],
|
||||
categories: ['Multi-player', 'Steam Achievements'],
|
||||
oslist: ['windows', 'linux'],
|
||||
sizeDownload: 35000000000,
|
||||
sizeOnDisk: 40000000000,
|
||||
controllerSupport: 'partial',
|
||||
steamDeckCompat: 'perfect',
|
||||
reviewScorePercent: 86,
|
||||
reviewCount: 1500000,
|
||||
metacriticScore: 89,
|
||||
steamChangeNumber: 24605165,
|
||||
publicBuildId: 12345678,
|
||||
releaseDate: '2012-08-21T00:00:00.000Z',
|
||||
timeEnriched: '2026-07-29T12:00:00.000Z'
|
||||
};
|
||||
|
||||
export const Library = {
|
||||
id: Id('userLibrary'),
|
||||
userId: Id('user'),
|
||||
gameId: Id('game'),
|
||||
playtime2w: 3600,
|
||||
playtimeForever: 150000,
|
||||
lastPlayed: '2026-07-28T12:00:00.000Z'
|
||||
};
|
||||
|
||||
export const Depot = {
|
||||
id: Id('gameDepot'),
|
||||
gameId: Id('game'),
|
||||
depotId: 731,
|
||||
branch: 'public',
|
||||
steamManifestId: '3183503801510301321',
|
||||
steamBuildId: 12345678,
|
||||
installedManifestId: '3183503801510301321',
|
||||
installedBuildId: 12345678,
|
||||
sizeDownload: 1000,
|
||||
sizeOnDisk: 2000,
|
||||
status: 'complete' as const,
|
||||
errorMessage: null,
|
||||
oslist: 'linux'
|
||||
};
|
||||
|
||||
export const AccessToken = {
|
||||
id: Id('accessToken'),
|
||||
ownerUserId: Id('user'),
|
||||
teamId: null,
|
||||
name: 'living-room-box',
|
||||
expiresAt: null,
|
||||
lastUsed: '2026-07-28T12:00:00.000Z'
|
||||
};
|
||||
|
||||
export const Machine = {
|
||||
id: Id('machine'),
|
||||
ownerUserId: Id('user'),
|
||||
teamId: null,
|
||||
label: 'living-room-box',
|
||||
lastSeen: '2026-07-28T12:00:00.000Z'
|
||||
};
|
||||
|
||||
export const GameDownload = {
|
||||
id: Id('gameDownload'),
|
||||
hostId: Id('machine'),
|
||||
gameId: Id('game'),
|
||||
status: 'downloading' as const,
|
||||
progressBytes: 1073741824,
|
||||
totalBytes: 5000000000,
|
||||
timeStarted: '2026-07-28T12:00:00.000Z',
|
||||
timeCompleted: null,
|
||||
errorMessage: null
|
||||
};
|
||||
}
|
||||
48
packages/core/src/fn.ts
Normal file
48
packages/core/src/fn.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { z, type ZodType } from 'zod';
|
||||
|
||||
// Explicit return type structures capturing the .schema attachment
|
||||
export type WrappedFn<Arg1 extends ZodType, Callback extends (...args: any[]) => any> = ((
|
||||
input: z.input<Arg1>
|
||||
) => ReturnType<Callback>) & { schema: Arg1 };
|
||||
|
||||
export type WrappedDoubleFn<
|
||||
Arg1 extends ZodType,
|
||||
Arg2 extends ZodType,
|
||||
Callback extends (...args: any[]) => any
|
||||
> = ((input1: z.input<Arg1>, input2: z.input<Arg2>) => ReturnType<Callback>) & {
|
||||
schemas: [Arg1, Arg2];
|
||||
};
|
||||
|
||||
// Single Argument Function
|
||||
export function fn<Arg1 extends ZodType, Callback extends (arg: z.output<Arg1>) => any>(
|
||||
arg1: Arg1,
|
||||
cb: Callback
|
||||
): WrappedFn<Arg1, Callback> {
|
||||
const result = Object.assign(
|
||||
function (input: z.input<Arg1>): ReturnType<Callback> {
|
||||
const parsed = arg1.parse(input);
|
||||
return cb(parsed);
|
||||
},
|
||||
{ schema: arg1 }
|
||||
);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// Double Argument Function
|
||||
export function doublefn<
|
||||
Arg1 extends ZodType,
|
||||
Arg2 extends ZodType,
|
||||
Callback extends (arg1: z.output<Arg1>, arg2: z.output<Arg2>) => any
|
||||
>(arg1: Arg1, arg2: Arg2, cb: Callback): WrappedDoubleFn<Arg1, Arg2, Callback> {
|
||||
const result = Object.assign(
|
||||
function (input: z.input<Arg1>, input2: z.input<Arg2>): ReturnType<Callback> {
|
||||
const parsed = arg1.parse(input);
|
||||
const parsed2 = arg2.parse(input2);
|
||||
return cb(parsed, parsed2);
|
||||
},
|
||||
{ schemas: [arg1, arg2] as [Arg1, Arg2] }
|
||||
);
|
||||
|
||||
return result;
|
||||
}
|
||||
48
packages/core/src/game/depot.sql.ts
Normal file
48
packages/core/src/game/depot.sql.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { sql } from 'drizzle-orm';
|
||||
import { bigint, index, integer, pgEnum, pgTable, text, uniqueIndex } from 'drizzle-orm/pg-core';
|
||||
|
||||
import { id, timestamps, ulid } from '../db/types.js';
|
||||
import { GameTable } from '../game/game.sql.js';
|
||||
|
||||
export const DepotStatus = pgEnum('depot_status', [
|
||||
'pending',
|
||||
'downloading',
|
||||
'complete',
|
||||
'error',
|
||||
'deleted'
|
||||
]);
|
||||
|
||||
export const GameDepotTable = pgTable(
|
||||
'game_depot',
|
||||
{
|
||||
...id,
|
||||
...timestamps,
|
||||
gameId: ulid('game_id')
|
||||
.notNull()
|
||||
.references(() => GameTable.id, { onDelete: 'cascade' }),
|
||||
|
||||
depotId: integer('depot_id').notNull(),
|
||||
branch: text('branch').notNull().default('public'),
|
||||
|
||||
steamManifestId: text('steam_manifest_id'),
|
||||
steamBuildId: integer('steam_build_id'),
|
||||
|
||||
installedManifestId: text('installed_manifest_id'),
|
||||
installedBuildId: integer('installed_build_id'),
|
||||
|
||||
sizeDownload: bigint('size_download', { mode: 'number' }),
|
||||
sizeOnDisk: bigint('size_on_disk', { mode: 'number' }),
|
||||
|
||||
status: DepotStatus('status').notNull().default('pending'),
|
||||
errorMessage: text('error_message'),
|
||||
|
||||
oslist: text('oslist')
|
||||
},
|
||||
(t) => [
|
||||
uniqueIndex('game_depot_unique').on(t.gameId, t.depotId, t.branch),
|
||||
index('game_depot_game_idx').on(t.gameId),
|
||||
index('game_depot_updates_idx')
|
||||
.on(t.gameId)
|
||||
.where(sql`${t.installedManifestId} is distinct from ${t.steamManifestId}`)
|
||||
]
|
||||
);
|
||||
266
packages/core/src/game/depot.ts
Normal file
266
packages/core/src/game/depot.ts
Normal file
@@ -0,0 +1,266 @@
|
||||
import { eq, and, isNull, sql, inArray } from 'drizzle-orm';
|
||||
import z from 'zod';
|
||||
|
||||
import { Database } from '../db/index.js';
|
||||
import { Examples } from '../examples.js';
|
||||
import { fn } from '../fn.js';
|
||||
import { DepotStatus, GameDepotTable } from './depot.sql.js';
|
||||
|
||||
export namespace Depot {
|
||||
export const Info = z
|
||||
.object({
|
||||
id: z.string().meta({
|
||||
description: 'Unique identifier for the depot entry',
|
||||
example: Examples.Depot.id
|
||||
}),
|
||||
gameId: z.string().meta({
|
||||
description: 'The game this depot belongs to',
|
||||
example: Examples.Depot.gameId
|
||||
}),
|
||||
depotId: z.number().int().meta({
|
||||
description: 'Steam depot ID',
|
||||
example: Examples.Depot.depotId
|
||||
}),
|
||||
branch: z.string().meta({
|
||||
description: 'Depot branch (e.g. public)',
|
||||
example: Examples.Depot.branch
|
||||
}),
|
||||
steamManifestId: z.string().nullable().optional().meta({
|
||||
description: 'Current manifest ID from Steam',
|
||||
example: Examples.Depot.steamManifestId
|
||||
}),
|
||||
steamBuildId: z.number().int().nullable().optional().meta({
|
||||
description: 'Current build ID from Steam',
|
||||
example: Examples.Depot.steamBuildId
|
||||
}),
|
||||
installedManifestId: z.string().nullable().optional().meta({
|
||||
description: 'Installed manifest ID on this host',
|
||||
example: Examples.Depot.installedManifestId
|
||||
}),
|
||||
installedBuildId: z.number().int().nullable().optional().meta({
|
||||
description: 'Installed build ID on this host',
|
||||
example: Examples.Depot.installedBuildId
|
||||
}),
|
||||
sizeDownload: z.number().nullable().optional().meta({
|
||||
description: 'Compressed download size in bytes',
|
||||
example: Examples.Depot.sizeDownload
|
||||
}),
|
||||
sizeOnDisk: z.number().nullable().optional().meta({
|
||||
description: 'Uncompressed size in bytes',
|
||||
example: Examples.Depot.sizeOnDisk
|
||||
}),
|
||||
status: z.enum(DepotStatus.enumValues).meta({
|
||||
description: 'Current depot status',
|
||||
example: Examples.Depot.status
|
||||
}),
|
||||
errorMessage: z.string().nullable().optional().meta({
|
||||
description: 'Error message if status is error',
|
||||
example: Examples.Depot.errorMessage
|
||||
}),
|
||||
oslist: z.string().nullable().optional().meta({
|
||||
description: 'OS filter (windows, linux, mac)',
|
||||
example: Examples.Depot.oslist
|
||||
})
|
||||
})
|
||||
.meta({
|
||||
ref: 'Depot',
|
||||
description: 'A game depot (shared install/update tracking)',
|
||||
example: Examples.Depot
|
||||
});
|
||||
|
||||
export type Info = z.infer<typeof Info>;
|
||||
|
||||
export const create = fn(
|
||||
Info.pick({
|
||||
id: true,
|
||||
gameId: true,
|
||||
depotId: true,
|
||||
branch: true,
|
||||
steamManifestId: true,
|
||||
steamBuildId: true,
|
||||
installedManifestId: true,
|
||||
installedBuildId: true,
|
||||
sizeDownload: true,
|
||||
sizeOnDisk: true,
|
||||
status: true,
|
||||
errorMessage: true,
|
||||
oslist: true
|
||||
}),
|
||||
async (input) => {
|
||||
await Database.use(async (tx) => {
|
||||
await tx.insert(GameDepotTable).values({
|
||||
id: input.id,
|
||||
gameId: input.gameId,
|
||||
depotId: input.depotId,
|
||||
branch: input.branch ?? 'public',
|
||||
steamManifestId: input.steamManifestId ?? null,
|
||||
steamBuildId: input.steamBuildId ?? null,
|
||||
installedManifestId: input.installedManifestId ?? null,
|
||||
installedBuildId: input.installedBuildId ?? null,
|
||||
sizeDownload: input.sizeDownload ?? null,
|
||||
sizeOnDisk: input.sizeOnDisk ?? null,
|
||||
status: input.status ?? 'pending',
|
||||
errorMessage: input.errorMessage ?? null,
|
||||
oslist: input.oslist ?? null
|
||||
});
|
||||
});
|
||||
return input.id;
|
||||
}
|
||||
);
|
||||
|
||||
export const upsert = fn(
|
||||
Info.pick({
|
||||
id: true,
|
||||
gameId: true,
|
||||
depotId: true,
|
||||
branch: true,
|
||||
steamManifestId: true,
|
||||
steamBuildId: true,
|
||||
installedManifestId: true,
|
||||
installedBuildId: true,
|
||||
sizeDownload: true,
|
||||
sizeOnDisk: true,
|
||||
status: true,
|
||||
errorMessage: true,
|
||||
oslist: true
|
||||
}),
|
||||
async (input) => {
|
||||
await Database.use(async (tx) => {
|
||||
await tx
|
||||
.insert(GameDepotTable)
|
||||
.values({
|
||||
id: input.id,
|
||||
gameId: input.gameId,
|
||||
depotId: input.depotId,
|
||||
branch: input.branch ?? 'public',
|
||||
steamManifestId: input.steamManifestId ?? null,
|
||||
steamBuildId: input.steamBuildId ?? null,
|
||||
installedManifestId: input.installedManifestId ?? null,
|
||||
installedBuildId: input.installedBuildId ?? null,
|
||||
sizeDownload: input.sizeDownload ?? null,
|
||||
sizeOnDisk: input.sizeOnDisk ?? null,
|
||||
status: input.status ?? 'pending',
|
||||
errorMessage: input.errorMessage ?? null,
|
||||
oslist: input.oslist ?? null
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: [GameDepotTable.gameId, GameDepotTable.depotId, GameDepotTable.branch],
|
||||
set: {
|
||||
steamManifestId: sql`excluded.${GameDepotTable.steamManifestId.name}`,
|
||||
steamBuildId: sql`excluded.${GameDepotTable.steamBuildId.name}`,
|
||||
sizeDownload: sql`excluded.${GameDepotTable.sizeDownload.name}`,
|
||||
sizeOnDisk: sql`excluded.${GameDepotTable.sizeOnDisk.name}`,
|
||||
oslist: sql`excluded.${GameDepotTable.oslist.name}`
|
||||
// Do not clobber installed_* fields — those are set by DepotJob
|
||||
}
|
||||
});
|
||||
});
|
||||
return input.id;
|
||||
}
|
||||
);
|
||||
|
||||
export const markInstalled = fn(
|
||||
Info.pick({ id: true, installedManifestId: true, installedBuildId: true, status: true }),
|
||||
async (input) => {
|
||||
await Database.use(async (tx) => {
|
||||
await tx
|
||||
.update(GameDepotTable)
|
||||
.set({
|
||||
installedManifestId: input.installedManifestId,
|
||||
installedBuildId: input.installedBuildId,
|
||||
status: input.status ?? 'complete'
|
||||
})
|
||||
.where(eq(GameDepotTable.id, input.id));
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
export const fromID = fn(Info.shape.id, async (id) => {
|
||||
return Database.use(async (tx) => {
|
||||
return tx
|
||||
.select()
|
||||
.from(GameDepotTable)
|
||||
.where(and(eq(GameDepotTable.id, id), isNull(GameDepotTable.timeDeleted)))
|
||||
.then((rows) => rows.at(0) ?? null);
|
||||
});
|
||||
});
|
||||
|
||||
export const listByGame = fn(Info.shape.gameId, async (gameId) => {
|
||||
return Database.use(async (tx) => {
|
||||
return tx
|
||||
.select()
|
||||
.from(GameDepotTable)
|
||||
.where(and(eq(GameDepotTable.gameId, gameId), isNull(GameDepotTable.timeDeleted)));
|
||||
});
|
||||
});
|
||||
|
||||
export const listByGameIDs = fn(z.array(z.string()), async (gameIds) => {
|
||||
if (gameIds.length === 0) return [];
|
||||
return Database.use(async (tx) => {
|
||||
return tx
|
||||
.select()
|
||||
.from(GameDepotTable)
|
||||
.where(and(inArray(GameDepotTable.gameId, gameIds), isNull(GameDepotTable.timeDeleted)));
|
||||
});
|
||||
});
|
||||
|
||||
export const listByGameAndDepotIDs = fn(
|
||||
z.object({ gameIds: z.array(z.string()), depotIds: z.array(z.number().int()) }),
|
||||
async (input) => {
|
||||
if (input.gameIds.length === 0 || input.depotIds.length === 0) return [];
|
||||
return Database.use(async (tx) => {
|
||||
return tx
|
||||
.select()
|
||||
.from(GameDepotTable)
|
||||
.where(
|
||||
and(
|
||||
inArray(GameDepotTable.gameId, input.gameIds),
|
||||
inArray(GameDepotTable.depotId, input.depotIds),
|
||||
isNull(GameDepotTable.timeDeleted)
|
||||
)
|
||||
);
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
export const getInstalled = fn(z.void(), async () => {
|
||||
return Database.use(async (tx) => {
|
||||
return tx
|
||||
.select()
|
||||
.from(GameDepotTable)
|
||||
.where(
|
||||
and(
|
||||
isNull(GameDepotTable.timeDeleted),
|
||||
sql`${GameDepotTable.installedManifestId} IS NOT NULL`
|
||||
)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
export const remove = fn(Info.shape.id, async (id) => {
|
||||
await Database.use(async (tx) => {
|
||||
await tx
|
||||
.update(GameDepotTable)
|
||||
.set({ timeDeleted: sql`now()` })
|
||||
.where(eq(GameDepotTable.id, id));
|
||||
});
|
||||
});
|
||||
|
||||
export function serialize(input: typeof GameDepotTable.$inferSelect): z.infer<typeof Info> {
|
||||
return {
|
||||
id: input.id,
|
||||
gameId: input.gameId,
|
||||
depotId: input.depotId,
|
||||
branch: input.branch,
|
||||
steamManifestId: input.steamManifestId,
|
||||
steamBuildId: input.steamBuildId,
|
||||
installedManifestId: input.installedManifestId,
|
||||
installedBuildId: input.installedBuildId,
|
||||
sizeDownload: input.sizeDownload,
|
||||
sizeOnDisk: input.sizeOnDisk,
|
||||
status: input.status as Info['status'],
|
||||
errorMessage: input.errorMessage,
|
||||
oslist: input.oslist
|
||||
};
|
||||
}
|
||||
}
|
||||
38
packages/core/src/game/download.sql.ts
Normal file
38
packages/core/src/game/download.sql.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { bigint, index, pgEnum, pgTable, text, uniqueIndex } from 'drizzle-orm/pg-core';
|
||||
|
||||
import { id, timestamps, ulid, utc } from '../db/types.js';
|
||||
import { GameTable } from './game.sql.js';
|
||||
|
||||
export const GameDownloadStatus = pgEnum('game_download_status', [
|
||||
'pending',
|
||||
'verifying',
|
||||
'downloading',
|
||||
'ready',
|
||||
'failed'
|
||||
]);
|
||||
|
||||
export const GameDownloadTable = pgTable(
|
||||
'game_download',
|
||||
{
|
||||
...id,
|
||||
...timestamps,
|
||||
hostId: text('host_id').notNull(),
|
||||
gameId: ulid('game_id')
|
||||
.notNull()
|
||||
.references(() => GameTable.id, { onDelete: 'cascade' }),
|
||||
|
||||
status: GameDownloadStatus('status').notNull().default('pending'),
|
||||
|
||||
progressBytes: bigint('progress_bytes', { mode: 'number' }).default(0),
|
||||
totalBytes: bigint('total_bytes', { mode: 'number' }),
|
||||
|
||||
timeStarted: utc('time_started'),
|
||||
timeCompleted: utc('time_completed'),
|
||||
errorMessage: text('error_message')
|
||||
},
|
||||
(t) => [
|
||||
uniqueIndex('game_download_host_game_unique').on(t.hostId, t.gameId),
|
||||
index('game_download_game_idx').on(t.gameId),
|
||||
index('game_download_host_status_idx').on(t.hostId, t.status)
|
||||
]
|
||||
);
|
||||
230
packages/core/src/game/download.test.ts
Normal file
230
packages/core/src/game/download.test.ts
Normal file
@@ -0,0 +1,230 @@
|
||||
import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
|
||||
|
||||
import { testDb } from '../db/test.js';
|
||||
import { Game } from '../game/index.js';
|
||||
import { Identifier } from '../id.js';
|
||||
import { GameDownload } from './download.js';
|
||||
|
||||
const sql = testDb();
|
||||
|
||||
const HOST_A = 'hst_aaaaaaaaaaaaaaaaaaaaaaaaa';
|
||||
const HOST_B = 'hst_bbbbbbbbbbbbbbbbbbbbbbbbb';
|
||||
|
||||
const createdGameIds: string[] = [];
|
||||
const gameIdByApp = new Map<number, string>();
|
||||
|
||||
async function ensureGame(steamAppId: number): Promise<string> {
|
||||
const existing = gameIdByApp.get(steamAppId);
|
||||
if (existing) return existing;
|
||||
const [row] = await Game.upsert({
|
||||
id: Identifier.ascending('game'),
|
||||
steamAppId,
|
||||
slug: `test-game-${steamAppId}`,
|
||||
name: `Test Game ${steamAppId}`
|
||||
});
|
||||
if (!row) throw new Error('expected a game row');
|
||||
createdGameIds.push(row.id);
|
||||
gameIdByApp.set(steamAppId, row.id);
|
||||
return row.id;
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
await ensureGame(4400);
|
||||
await ensureGame(4401);
|
||||
await ensureGame(4402);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (createdGameIds.length > 0) {
|
||||
// Deleting the games cascades to their game_download rows.
|
||||
await sql`delete from "game" where id in ${sql(createdGameIds)}`;
|
||||
createdGameIds.length = 0;
|
||||
}
|
||||
});
|
||||
|
||||
describe('GameDownload', () => {
|
||||
function expectRow<T>(row: T | null | undefined): T {
|
||||
expect(row).not.toBeNull();
|
||||
return row as T;
|
||||
}
|
||||
|
||||
test('a new host/game creates one row', async () => {
|
||||
const gameId = await ensureGame(4400);
|
||||
const row = expectRow(
|
||||
await GameDownload.upsertState({
|
||||
hostId: HOST_A,
|
||||
gameId,
|
||||
status: 'downloading',
|
||||
progressBytes: 1000,
|
||||
totalBytes: 5000
|
||||
})
|
||||
);
|
||||
|
||||
expect(row.id).toMatch(/^gdl_/);
|
||||
expect(row.hostId).toBe(HOST_A);
|
||||
expect(row.gameId).toBe(gameId);
|
||||
expect(row.status).toBe('downloading');
|
||||
expect(row.timeStarted).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
test('repeating the same host/game updates one row', async () => {
|
||||
const gameId = await ensureGame(4400);
|
||||
const first = expectRow(
|
||||
await GameDownload.upsertState({
|
||||
hostId: HOST_A,
|
||||
gameId,
|
||||
status: 'downloading',
|
||||
progressBytes: 1000,
|
||||
totalBytes: 5000
|
||||
})
|
||||
);
|
||||
const second = expectRow(
|
||||
await GameDownload.upsertState({
|
||||
hostId: HOST_A,
|
||||
gameId,
|
||||
status: 'downloading',
|
||||
progressBytes: 2000,
|
||||
totalBytes: 5000
|
||||
})
|
||||
);
|
||||
|
||||
expect(second.id).toBe(first.id);
|
||||
expect(second.progressBytes).toBe(2000);
|
||||
expect(second.totalBytes).toBe(5000);
|
||||
|
||||
const count =
|
||||
await sql`select count(*)::int as n from "game_download" where host_id = ${HOST_A} and game_id = ${gameId}`;
|
||||
expect(count[0]?.n).toBe(1);
|
||||
});
|
||||
|
||||
test('reports for other users do not create another row', async () => {
|
||||
const gameId = await ensureGame(4401);
|
||||
// No user dimension exists on the shared state row: reports for any
|
||||
// user land on the single (host, game) row.
|
||||
await GameDownload.upsertState({
|
||||
hostId: HOST_A,
|
||||
gameId,
|
||||
status: 'downloading',
|
||||
progressBytes: 1
|
||||
});
|
||||
await GameDownload.upsertState({
|
||||
hostId: HOST_A,
|
||||
gameId,
|
||||
status: 'downloading',
|
||||
progressBytes: 2
|
||||
});
|
||||
const rows = await GameDownload.listByHost(HOST_A);
|
||||
expect(rows.filter((r) => r.gameId === gameId).length).toBe(1);
|
||||
});
|
||||
|
||||
test('two hosts create two independent rows', async () => {
|
||||
const gameId = await ensureGame(4401);
|
||||
const a = expectRow(
|
||||
await GameDownload.upsertState({
|
||||
hostId: HOST_A,
|
||||
gameId,
|
||||
status: 'downloading'
|
||||
})
|
||||
);
|
||||
const b = expectRow(
|
||||
await GameDownload.upsertState({
|
||||
hostId: HOST_B,
|
||||
gameId,
|
||||
status: 'downloading'
|
||||
})
|
||||
);
|
||||
|
||||
expect(a.id).not.toBe(b.id);
|
||||
expect(a.hostId).toBe(HOST_A);
|
||||
expect(b.hostId).toBe(HOST_B);
|
||||
});
|
||||
|
||||
test('verifying is accepted', async () => {
|
||||
const gameId = await ensureGame(4401);
|
||||
const row = expectRow(
|
||||
await GameDownload.upsertState({
|
||||
hostId: HOST_A,
|
||||
gameId,
|
||||
status: 'verifying',
|
||||
progressBytes: 2048
|
||||
})
|
||||
);
|
||||
expect(row.status).toBe('verifying');
|
||||
expect(row.timeStarted).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
test('ready sets timeCompleted', async () => {
|
||||
const gameId = await ensureGame(4400);
|
||||
const row = expectRow(
|
||||
await GameDownload.upsertState({
|
||||
hostId: HOST_A,
|
||||
gameId,
|
||||
status: 'ready',
|
||||
progressBytes: 5000,
|
||||
totalBytes: 5000
|
||||
})
|
||||
);
|
||||
expect(row.status).toBe('ready');
|
||||
expect(row.timeCompleted).toBeInstanceOf(Date);
|
||||
expect(row.timeStarted).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
test('failed records an error', async () => {
|
||||
const gameId = await ensureGame(4401);
|
||||
const row = expectRow(
|
||||
await GameDownload.upsertState({
|
||||
hostId: HOST_A,
|
||||
gameId,
|
||||
status: 'failed',
|
||||
errorMessage: 'depot key missing'
|
||||
})
|
||||
);
|
||||
expect(row.status).toBe('failed');
|
||||
expect(row.errorMessage).toBe('depot key missing');
|
||||
});
|
||||
|
||||
test('progress updates do not require a user ID', async () => {
|
||||
const gameId = await ensureGame(4402);
|
||||
const row = expectRow(
|
||||
await GameDownload.upsertState({
|
||||
hostId: HOST_B,
|
||||
gameId,
|
||||
status: 'downloading',
|
||||
progressBytes: 12345
|
||||
})
|
||||
);
|
||||
expect(row.progressBytes).toBe(12345);
|
||||
expect(row).not.toHaveProperty('userId');
|
||||
});
|
||||
|
||||
test('old user_download table is no longer referenced', async () => {
|
||||
const rows =
|
||||
await sql`select table_name from information_schema.tables where table_schema = 'public' and table_name = 'user_download'`;
|
||||
expect(rows.length).toBe(0);
|
||||
});
|
||||
|
||||
test('findByHostAndGame and listByGame', async () => {
|
||||
const gameId = await ensureGame(4402);
|
||||
const found = await GameDownload.findByHostAndGame({ hostId: HOST_B, gameId });
|
||||
expect(found).not.toBeNull();
|
||||
expect(found!.gameId).toBe(gameId);
|
||||
|
||||
const byGame = await GameDownload.listByGame(gameId);
|
||||
expect(byGame.some((r) => r.hostId === HOST_B)).toBe(true);
|
||||
});
|
||||
|
||||
test('markReady and markFailed update the row', async () => {
|
||||
const gameId = await ensureGame(4402);
|
||||
const ready = await GameDownload.markReady({ hostId: HOST_B, gameId });
|
||||
expect(ready!.status).toBe('ready');
|
||||
expect(ready!.timeCompleted).toBeInstanceOf(Date);
|
||||
|
||||
const failed = await GameDownload.markFailed({
|
||||
hostId: HOST_B,
|
||||
gameId,
|
||||
errorMessage: 'disk full'
|
||||
});
|
||||
expect(failed!.status).toBe('failed');
|
||||
expect(failed!.errorMessage).toBe('disk full');
|
||||
});
|
||||
});
|
||||
207
packages/core/src/game/download.ts
Normal file
207
packages/core/src/game/download.ts
Normal file
@@ -0,0 +1,207 @@
|
||||
import { and, eq, inArray, isNull, sql } from 'drizzle-orm';
|
||||
import z from 'zod';
|
||||
|
||||
import { Database } from '../db/index.js';
|
||||
import { Examples } from '../examples.js';
|
||||
import { fn } from '../fn.js';
|
||||
import { Identifier } from '../id.js';
|
||||
import { GameDownloadStatus, GameDownloadTable } from './download.sql.js';
|
||||
|
||||
export namespace GameDownload {
|
||||
export const Info = z
|
||||
.object({
|
||||
id: z.string().meta({
|
||||
description: 'Unique identifier for the download state record',
|
||||
example: Examples.GameDownload.id
|
||||
}),
|
||||
hostId: z.string().meta({
|
||||
description: 'The nessh host performing the download',
|
||||
example: Examples.GameDownload.hostId
|
||||
}),
|
||||
gameId: z.string().meta({
|
||||
description: 'The game being downloaded',
|
||||
example: Examples.GameDownload.gameId
|
||||
}),
|
||||
status: z.enum(GameDownloadStatus.enumValues).meta({
|
||||
description: 'Current download status',
|
||||
example: Examples.GameDownload.status
|
||||
}),
|
||||
progressBytes: z.number().nullable().optional().meta({
|
||||
description: 'Bytes downloaded so far',
|
||||
example: Examples.GameDownload.progressBytes
|
||||
}),
|
||||
totalBytes: z.number().nullable().optional().meta({
|
||||
description: 'Total bytes to download',
|
||||
example: Examples.GameDownload.totalBytes
|
||||
}),
|
||||
timeStarted: z.string().nullable().optional().meta({
|
||||
description: 'When the download started (ISO 8601)',
|
||||
example: Examples.GameDownload.timeStarted
|
||||
}),
|
||||
timeCompleted: z.string().nullable().optional().meta({
|
||||
description: 'When the download completed (ISO 8601)',
|
||||
example: Examples.GameDownload.timeCompleted
|
||||
}),
|
||||
errorMessage: z.string().nullable().optional().meta({
|
||||
description: 'Error message if status is failed',
|
||||
example: Examples.GameDownload.errorMessage
|
||||
})
|
||||
})
|
||||
.meta({
|
||||
ref: 'GameDownload',
|
||||
description: 'Per-host game download state, shared across users',
|
||||
example: Examples.GameDownload
|
||||
});
|
||||
|
||||
export type Info = z.infer<typeof Info>;
|
||||
|
||||
/**
|
||||
* Atomically insert or update the state row for a (host, game) pair and
|
||||
* return the actual database row. Timestamps are derived from status:
|
||||
* `downloading`/`verifying` set `timeStarted` (preserving an existing
|
||||
* start on resume), `ready` sets `timeCompleted`.
|
||||
*/
|
||||
export const upsertState = fn(
|
||||
Info.pick({
|
||||
hostId: true,
|
||||
gameId: true,
|
||||
status: true,
|
||||
progressBytes: true,
|
||||
totalBytes: true,
|
||||
errorMessage: true
|
||||
}),
|
||||
async (input) => {
|
||||
return Database.use(async (tx) => {
|
||||
const started = input.status === 'downloading' || input.status === 'verifying';
|
||||
const [row] = await tx
|
||||
.insert(GameDownloadTable)
|
||||
.values({
|
||||
id: Identifier.ascending('gameDownload'),
|
||||
hostId: input.hostId,
|
||||
gameId: input.gameId,
|
||||
status: input.status,
|
||||
progressBytes: input.progressBytes ?? null,
|
||||
totalBytes: input.totalBytes ?? null,
|
||||
errorMessage: input.status === 'failed' ? (input.errorMessage ?? null) : null,
|
||||
timeStarted: started ? new Date() : null,
|
||||
timeCompleted: input.status === 'ready' ? new Date() : null
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: [GameDownloadTable.hostId, GameDownloadTable.gameId],
|
||||
set: {
|
||||
status: sql`excluded.${sql.identifier(GameDownloadTable.status.name)}`,
|
||||
progressBytes: sql`coalesce(excluded.${sql.identifier(GameDownloadTable.progressBytes.name)}, ${GameDownloadTable.progressBytes})`,
|
||||
totalBytes: sql`coalesce(excluded.${sql.identifier(GameDownloadTable.totalBytes.name)}, ${GameDownloadTable.totalBytes})`,
|
||||
errorMessage: sql`case when excluded.${sql.identifier(GameDownloadTable.status.name)} = 'failed' then coalesce(excluded.${sql.identifier(GameDownloadTable.errorMessage.name)}, ${GameDownloadTable.errorMessage}) else null end`,
|
||||
timeStarted: sql`case when excluded.${sql.identifier(GameDownloadTable.status.name)} in ('downloading', 'verifying') then coalesce(${GameDownloadTable.timeStarted}, now()) else ${GameDownloadTable.timeStarted} end`,
|
||||
timeCompleted: sql`case when excluded.${sql.identifier(GameDownloadTable.status.name)} = 'ready' then now() else null end`
|
||||
}
|
||||
})
|
||||
.returning();
|
||||
return row;
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
export const findByHostAndGame = fn(Info.pick({ hostId: true, gameId: true }), async (input) => {
|
||||
return Database.use(async (tx) => {
|
||||
return tx
|
||||
.select()
|
||||
.from(GameDownloadTable)
|
||||
.where(
|
||||
and(
|
||||
eq(GameDownloadTable.hostId, input.hostId),
|
||||
eq(GameDownloadTable.gameId, input.gameId),
|
||||
isNull(GameDownloadTable.timeDeleted)
|
||||
)
|
||||
)
|
||||
.then((rows) => rows.at(0) ?? null);
|
||||
});
|
||||
});
|
||||
|
||||
export const listByGame = fn(Info.shape.gameId, async (gameId) => {
|
||||
return Database.use(async (tx) => {
|
||||
return tx
|
||||
.select()
|
||||
.from(GameDownloadTable)
|
||||
.where(and(eq(GameDownloadTable.gameId, gameId), isNull(GameDownloadTable.timeDeleted)))
|
||||
.orderBy(GameDownloadTable.timeCreated);
|
||||
});
|
||||
});
|
||||
|
||||
export const listByHost = fn(Info.shape.hostId, async (hostId) => {
|
||||
return Database.use(async (tx) => {
|
||||
return tx
|
||||
.select()
|
||||
.from(GameDownloadTable)
|
||||
.where(and(eq(GameDownloadTable.hostId, hostId), isNull(GameDownloadTable.timeDeleted)))
|
||||
.orderBy(GameDownloadTable.timeCreated);
|
||||
});
|
||||
});
|
||||
|
||||
export const listByGameIDs = fn(z.array(z.string()), async (gameIds) => {
|
||||
if (gameIds.length === 0) return [];
|
||||
return Database.use(async (tx) => {
|
||||
return tx
|
||||
.select()
|
||||
.from(GameDownloadTable)
|
||||
.where(
|
||||
and(inArray(GameDownloadTable.gameId, gameIds), isNull(GameDownloadTable.timeDeleted))
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
export const markReady = fn(Info.pick({ hostId: true, gameId: true }), async (input) => {
|
||||
return Database.use(async (tx) => {
|
||||
const [row] = await tx
|
||||
.update(GameDownloadTable)
|
||||
.set({ status: 'ready', timeCompleted: new Date() })
|
||||
.where(
|
||||
and(
|
||||
eq(GameDownloadTable.hostId, input.hostId),
|
||||
eq(GameDownloadTable.gameId, input.gameId),
|
||||
isNull(GameDownloadTable.timeDeleted)
|
||||
)
|
||||
)
|
||||
.returning();
|
||||
return row ?? null;
|
||||
});
|
||||
});
|
||||
|
||||
export const markFailed = fn(
|
||||
Info.pick({ hostId: true, gameId: true, errorMessage: true }),
|
||||
async (input) => {
|
||||
return Database.use(async (tx) => {
|
||||
const [row] = await tx
|
||||
.update(GameDownloadTable)
|
||||
.set({
|
||||
status: 'failed',
|
||||
errorMessage: input.errorMessage ?? null
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(GameDownloadTable.hostId, input.hostId),
|
||||
eq(GameDownloadTable.gameId, input.gameId),
|
||||
isNull(GameDownloadTable.timeDeleted)
|
||||
)
|
||||
)
|
||||
.returning();
|
||||
return row ?? null;
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
export function serialize(input: typeof GameDownloadTable.$inferSelect): z.infer<typeof Info> {
|
||||
return {
|
||||
id: input.id,
|
||||
hostId: input.hostId,
|
||||
gameId: input.gameId,
|
||||
status: input.status as Info['status'],
|
||||
progressBytes: input.progressBytes,
|
||||
totalBytes: input.totalBytes,
|
||||
timeStarted: input.timeStarted?.toISOString() ?? null,
|
||||
timeCompleted: input.timeCompleted?.toISOString() ?? null,
|
||||
errorMessage: input.errorMessage
|
||||
};
|
||||
}
|
||||
}
|
||||
48
packages/core/src/game/game.sql.ts
Normal file
48
packages/core/src/game/game.sql.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { bigint, integer, jsonb, pgTable, smallint, text, uniqueIndex } from 'drizzle-orm/pg-core';
|
||||
|
||||
import { id, timestamps, utc } from '../db/types.js';
|
||||
|
||||
export const GameTable = pgTable(
|
||||
'game',
|
||||
{
|
||||
...id,
|
||||
...timestamps,
|
||||
|
||||
steamAppId: integer('steam_app_id').notNull().unique(),
|
||||
slug: text('slug').notNull(),
|
||||
name: text('name').notNull(),
|
||||
type: text('type'),
|
||||
|
||||
clientIcon: text('client_icon'),
|
||||
icon: text('icon'),
|
||||
|
||||
shortDescription: text('short_description'),
|
||||
description: text('description'),
|
||||
|
||||
developers: jsonb('developers').$type<string[]>(),
|
||||
publishers: jsonb('publishers').$type<string[]>(),
|
||||
primaryGenre: text('primary_genre'),
|
||||
genres: jsonb('genres').$type<string[]>(),
|
||||
categories: jsonb('categories').$type<string[]>(),
|
||||
oslist: jsonb('oslist').$type<string[]>(),
|
||||
|
||||
sizeDownload: bigint('size_download', { mode: 'number' }),
|
||||
sizeOnDisk: bigint('size_on_disk', { mode: 'number' }),
|
||||
|
||||
controllerSupport: text('controller_support'),
|
||||
steamDeckCompat: text('steam_deck_compat'),
|
||||
reviewScorePercent: smallint('review_score_percent'),
|
||||
reviewCount: integer('review_count'),
|
||||
metacriticScore: smallint('metacritic_score'),
|
||||
|
||||
steamChangeNumber: integer('steam_change_number'),
|
||||
publicBuildId: integer('public_build_id'),
|
||||
releaseDate: utc('release_date_utc'),
|
||||
|
||||
timeEnriched: utc('time_enriched')
|
||||
},
|
||||
(t) => [
|
||||
uniqueIndex('game_slug_unique').on(t.slug),
|
||||
uniqueIndex('game_app_id_unique').on(t.steamAppId)
|
||||
]
|
||||
);
|
||||
388
packages/core/src/game/index.ts
Normal file
388
packages/core/src/game/index.ts
Normal file
@@ -0,0 +1,388 @@
|
||||
import { eq, and, isNull, sql, inArray } from 'drizzle-orm';
|
||||
import z from 'zod';
|
||||
|
||||
import { Database } from '../db/index.js';
|
||||
import { Examples } from '../examples.js';
|
||||
import { fn } from '../fn.js';
|
||||
import { GameDownload } from './download.js';
|
||||
import { GameTable } from './game.sql.js';
|
||||
|
||||
export { GameDownload };
|
||||
|
||||
export namespace Game {
|
||||
export const Info = z
|
||||
.object({
|
||||
id: z.string().meta({
|
||||
description: 'Unique identifier for the game',
|
||||
example: Examples.Game.id
|
||||
}),
|
||||
steamAppId: z.number().int().meta({
|
||||
description: 'Steam application ID',
|
||||
example: Examples.Game.steamAppId
|
||||
}),
|
||||
slug: z.string().meta({
|
||||
description: 'URL-friendly slug',
|
||||
example: Examples.Game.slug
|
||||
}),
|
||||
name: z.string().meta({
|
||||
description: 'Game title',
|
||||
example: Examples.Game.name
|
||||
}),
|
||||
type: z.string().nullable().optional().meta({
|
||||
description: 'Content type (game, dlc, demo, tool)',
|
||||
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 marketing description',
|
||||
example: Examples.Game.shortDescription
|
||||
}),
|
||||
description: z.string().nullable().optional().meta({
|
||||
description: 'Full game 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
|
||||
}),
|
||||
primaryGenre: z.string().nullable().optional().meta({
|
||||
description: 'Primary genre label',
|
||||
example: Examples.Game.primaryGenre
|
||||
}),
|
||||
genres: z.array(z.string()).nullable().optional().meta({
|
||||
description: 'All genre labels',
|
||||
example: Examples.Game.genres
|
||||
}),
|
||||
categories: z.array(z.string()).nullable().optional().meta({
|
||||
description: 'Steam store categories (Multi-player, Achievements, etc.)',
|
||||
example: Examples.Game.categories
|
||||
}),
|
||||
oslist: z.array(z.string()).nullable().optional().meta({
|
||||
description: 'Supported operating systems',
|
||||
example: Examples.Game.oslist
|
||||
}),
|
||||
sizeDownload: z.number().nullable().optional().meta({
|
||||
description: 'Compressed download size in bytes',
|
||||
example: Examples.Game.sizeDownload
|
||||
}),
|
||||
sizeOnDisk: z.number().nullable().optional().meta({
|
||||
description: 'Uncompressed install size in bytes',
|
||||
example: Examples.Game.sizeOnDisk
|
||||
}),
|
||||
controllerSupport: z.string().nullable().optional().meta({
|
||||
description: 'Controller support level',
|
||||
example: Examples.Game.controllerSupport
|
||||
}),
|
||||
steamDeckCompat: z.string().nullable().optional().meta({
|
||||
description: 'Steam Deck compatibility rating',
|
||||
example: Examples.Game.steamDeckCompat
|
||||
}),
|
||||
reviewScorePercent: z.number().int().nullable().optional().meta({
|
||||
description: 'Review score percentage (0–100)',
|
||||
example: Examples.Game.reviewScorePercent
|
||||
}),
|
||||
reviewCount: z.number().int().nullable().optional().meta({
|
||||
description: 'Total review count',
|
||||
example: Examples.Game.reviewCount
|
||||
}),
|
||||
metacriticScore: z.number().int().nullable().optional().meta({
|
||||
description: 'Metacritic score',
|
||||
example: Examples.Game.metacriticScore
|
||||
}),
|
||||
steamChangeNumber: z.number().int().nullable().optional().meta({
|
||||
description: 'PICS change number for current version',
|
||||
example: Examples.Game.steamChangeNumber
|
||||
}),
|
||||
publicBuildId: z.number().int().nullable().optional().meta({
|
||||
description: 'Public branch build ID',
|
||||
example: Examples.Game.publicBuildId
|
||||
}),
|
||||
releaseDate: z.string().nullable().optional().meta({
|
||||
description: 'Release date (ISO 8601)',
|
||||
example: Examples.Game.releaseDate
|
||||
}),
|
||||
timeEnriched: z.string().nullable().optional().meta({
|
||||
description: 'When full metadata was last enriched from PICS',
|
||||
example: Examples.Game.timeEnriched
|
||||
})
|
||||
})
|
||||
.meta({
|
||||
ref: 'Game',
|
||||
description: 'A game in the global catalog',
|
||||
example: Examples.Game
|
||||
});
|
||||
|
||||
export type Info = z.infer<typeof Info>;
|
||||
|
||||
export const create = fn(
|
||||
Info.pick({
|
||||
id: true,
|
||||
steamAppId: true,
|
||||
slug: true,
|
||||
name: true,
|
||||
type: true,
|
||||
clientIcon: true,
|
||||
icon: true,
|
||||
shortDescription: true,
|
||||
description: true,
|
||||
developers: true,
|
||||
publishers: true,
|
||||
primaryGenre: true,
|
||||
genres: true,
|
||||
categories: true,
|
||||
oslist: true,
|
||||
sizeDownload: true,
|
||||
sizeOnDisk: true,
|
||||
controllerSupport: true,
|
||||
steamDeckCompat: true,
|
||||
reviewScorePercent: true,
|
||||
reviewCount: true,
|
||||
metacriticScore: true,
|
||||
steamChangeNumber: true,
|
||||
publicBuildId: true,
|
||||
releaseDate: true,
|
||||
timeEnriched: true
|
||||
}),
|
||||
async (input) => {
|
||||
await Database.use(async (tx) => {
|
||||
await tx.insert(GameTable).values({
|
||||
id: input.id,
|
||||
steamAppId: input.steamAppId,
|
||||
slug: input.slug,
|
||||
name: input.name,
|
||||
type: input.type ?? null,
|
||||
clientIcon: input.clientIcon ?? null,
|
||||
icon: input.icon ?? null,
|
||||
shortDescription: input.shortDescription ?? null,
|
||||
description: input.description ?? null,
|
||||
developers: input.developers ?? null,
|
||||
publishers: input.publishers ?? null,
|
||||
primaryGenre: input.primaryGenre ?? null,
|
||||
genres: input.genres ?? null,
|
||||
categories: input.categories ?? null,
|
||||
oslist: input.oslist ?? null,
|
||||
sizeDownload: input.sizeDownload ?? null,
|
||||
sizeOnDisk: input.sizeOnDisk ?? null,
|
||||
controllerSupport: input.controllerSupport ?? null,
|
||||
steamDeckCompat: input.steamDeckCompat ?? null,
|
||||
reviewScorePercent: input.reviewScorePercent ?? null,
|
||||
reviewCount: input.reviewCount ?? null,
|
||||
metacriticScore: input.metacriticScore ?? null,
|
||||
steamChangeNumber: input.steamChangeNumber ?? null,
|
||||
publicBuildId: input.publicBuildId ?? null,
|
||||
releaseDate: input.releaseDate ? new Date(input.releaseDate) : null,
|
||||
timeEnriched: input.timeEnriched ? new Date(input.timeEnriched) : null
|
||||
});
|
||||
});
|
||||
return input.id;
|
||||
}
|
||||
);
|
||||
|
||||
export const fromID = fn(Info.shape.id, async (id) => {
|
||||
return Database.use(async (tx) => {
|
||||
return tx
|
||||
.select()
|
||||
.from(GameTable)
|
||||
.where(and(eq(GameTable.id, id), isNull(GameTable.timeDeleted)))
|
||||
.then((rows) => rows.at(0) ?? null);
|
||||
});
|
||||
});
|
||||
|
||||
export const fromSteamAppID = fn(z.number().int(), async (steamAppId) => {
|
||||
return Database.use(async (tx) => {
|
||||
return tx
|
||||
.select()
|
||||
.from(GameTable)
|
||||
.where(and(eq(GameTable.steamAppId, steamAppId), isNull(GameTable.timeDeleted)))
|
||||
.then((rows) => rows.at(0) ?? null);
|
||||
});
|
||||
});
|
||||
|
||||
export const upsert = fn(
|
||||
Info.pick({
|
||||
id: true,
|
||||
steamAppId: true,
|
||||
slug: true,
|
||||
name: true,
|
||||
type: true,
|
||||
clientIcon: true,
|
||||
icon: true,
|
||||
shortDescription: true,
|
||||
description: true,
|
||||
developers: true,
|
||||
publishers: true,
|
||||
primaryGenre: true,
|
||||
genres: true,
|
||||
categories: true,
|
||||
oslist: true,
|
||||
sizeDownload: true,
|
||||
sizeOnDisk: true,
|
||||
controllerSupport: true,
|
||||
steamDeckCompat: true,
|
||||
reviewScorePercent: true,
|
||||
reviewCount: true,
|
||||
metacriticScore: true,
|
||||
steamChangeNumber: true,
|
||||
publicBuildId: true,
|
||||
releaseDate: true,
|
||||
timeEnriched: true
|
||||
}),
|
||||
async (input) =>
|
||||
Database.use(async (tx) =>
|
||||
tx
|
||||
.insert(GameTable)
|
||||
.values({
|
||||
id: input.id,
|
||||
steamAppId: input.steamAppId,
|
||||
slug: input.slug,
|
||||
name: input.name,
|
||||
type: input.type ?? null,
|
||||
clientIcon: input.clientIcon ?? null,
|
||||
icon: input.icon ?? null,
|
||||
shortDescription: input.shortDescription ?? null,
|
||||
description: input.description ?? null,
|
||||
developers: input.developers ?? null,
|
||||
publishers: input.publishers ?? null,
|
||||
primaryGenre: input.primaryGenre ?? null,
|
||||
genres: input.genres ?? null,
|
||||
categories: input.categories ?? null,
|
||||
oslist: input.oslist ?? null,
|
||||
sizeDownload: input.sizeDownload ?? null,
|
||||
sizeOnDisk: input.sizeOnDisk ?? null,
|
||||
controllerSupport: input.controllerSupport ?? null,
|
||||
steamDeckCompat: input.steamDeckCompat ?? null,
|
||||
reviewScorePercent: input.reviewScorePercent ?? null,
|
||||
reviewCount: input.reviewCount ?? null,
|
||||
metacriticScore: input.metacriticScore ?? null,
|
||||
steamChangeNumber: input.steamChangeNumber ?? null,
|
||||
publicBuildId: input.publicBuildId ?? null,
|
||||
releaseDate: input.releaseDate ? new Date(input.releaseDate) : null,
|
||||
timeEnriched: input.timeEnriched ? new Date(input.timeEnriched) : null
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: GameTable.steamAppId,
|
||||
set: {
|
||||
slug: input.slug,
|
||||
name: input.name,
|
||||
type: input.type ?? null,
|
||||
clientIcon: input.clientIcon ?? null,
|
||||
icon: input.icon ?? null,
|
||||
shortDescription: input.shortDescription ?? null,
|
||||
description: input.description ?? null,
|
||||
developers: input.developers ?? null,
|
||||
publishers: input.publishers ?? null,
|
||||
primaryGenre: input.primaryGenre ?? null,
|
||||
genres: input.genres ?? null,
|
||||
categories: input.categories ?? null,
|
||||
oslist: input.oslist ?? null,
|
||||
sizeDownload: input.sizeDownload ?? null,
|
||||
sizeOnDisk: input.sizeOnDisk ?? null,
|
||||
controllerSupport: input.controllerSupport ?? null,
|
||||
steamDeckCompat: input.steamDeckCompat ?? null,
|
||||
reviewScorePercent: input.reviewScorePercent ?? null,
|
||||
reviewCount: input.reviewCount ?? null,
|
||||
metacriticScore: input.metacriticScore ?? null,
|
||||
steamChangeNumber: input.steamChangeNumber ?? null,
|
||||
publicBuildId: input.publicBuildId ?? null,
|
||||
releaseDate: input.releaseDate ? new Date(input.releaseDate) : null,
|
||||
timeEnriched: input.timeEnriched ? new Date(input.timeEnriched) : null
|
||||
}
|
||||
})
|
||||
.returning()
|
||||
)
|
||||
);
|
||||
|
||||
export const searchByName = fn(z.string(), async (query) => {
|
||||
return Database.use(async (tx) => {
|
||||
return tx
|
||||
.select()
|
||||
.from(GameTable)
|
||||
.where(
|
||||
and(isNull(GameTable.timeDeleted), sql`${GameTable.name} ILIKE ${'%' + query + '%'}`)
|
||||
)
|
||||
.orderBy(GameTable.name)
|
||||
.limit(50);
|
||||
});
|
||||
});
|
||||
|
||||
export const listUnenriched = fn(z.number().int().default(50), async (limit) => {
|
||||
return Database.use(async (tx) => {
|
||||
return tx
|
||||
.select()
|
||||
.from(GameTable)
|
||||
.where(and(isNull(GameTable.timeEnriched), isNull(GameTable.timeDeleted)))
|
||||
.limit(limit);
|
||||
});
|
||||
});
|
||||
|
||||
export const listByIDs = fn(z.array(z.string()), async (ids) => {
|
||||
if (ids.length === 0) return [];
|
||||
return Database.use(async (tx) => {
|
||||
return tx
|
||||
.select()
|
||||
.from(GameTable)
|
||||
.where(and(inArray(GameTable.id, ids), isNull(GameTable.timeDeleted)));
|
||||
});
|
||||
});
|
||||
|
||||
export const listByAppIDs = fn(z.array(z.number().int()), async (appIds) => {
|
||||
if (appIds.length === 0) return [];
|
||||
return Database.use(async (tx) => {
|
||||
return tx
|
||||
.select()
|
||||
.from(GameTable)
|
||||
.where(and(inArray(GameTable.steamAppId, appIds), isNull(GameTable.timeDeleted)));
|
||||
});
|
||||
});
|
||||
|
||||
export const remove = fn(Info.shape.id, async (id) => {
|
||||
await Database.use(async (tx) => {
|
||||
await tx
|
||||
.update(GameTable)
|
||||
.set({ timeDeleted: sql`now()` })
|
||||
.where(eq(GameTable.id, id));
|
||||
});
|
||||
});
|
||||
|
||||
export function serialize(input: typeof GameTable.$inferSelect): z.infer<typeof Info> {
|
||||
return {
|
||||
id: input.id,
|
||||
steamAppId: input.steamAppId,
|
||||
slug: input.slug,
|
||||
name: input.name,
|
||||
type: input.type,
|
||||
clientIcon: input.clientIcon,
|
||||
icon: input.icon,
|
||||
shortDescription: input.shortDescription,
|
||||
description: input.description,
|
||||
developers: input.developers,
|
||||
publishers: input.publishers,
|
||||
primaryGenre: input.primaryGenre,
|
||||
genres: input.genres,
|
||||
categories: input.categories,
|
||||
oslist: input.oslist,
|
||||
sizeDownload: input.sizeDownload,
|
||||
sizeOnDisk: input.sizeOnDisk,
|
||||
controllerSupport: input.controllerSupport,
|
||||
steamDeckCompat: input.steamDeckCompat,
|
||||
reviewScorePercent: input.reviewScorePercent,
|
||||
reviewCount: input.reviewCount,
|
||||
metacriticScore: input.metacriticScore,
|
||||
steamChangeNumber: input.steamChangeNumber,
|
||||
publicBuildId: input.publicBuildId,
|
||||
releaseDate: input.releaseDate?.toISOString() ?? null,
|
||||
timeEnriched: input.timeEnriched?.toISOString() ?? null
|
||||
};
|
||||
}
|
||||
}
|
||||
80
packages/core/src/id.ts
Normal file
80
packages/core/src/id.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { randomBytes } from 'crypto';
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
export namespace Identifier {
|
||||
export const prefixes = {
|
||||
user: 'usr',
|
||||
linkedAccount: 'lac',
|
||||
team: 'tem',
|
||||
teamMember: 'mem',
|
||||
verification: 'ver',
|
||||
userFingerprint: 'ufp',
|
||||
pairingCode: 'pai',
|
||||
machine: 'mch',
|
||||
accessToken: 'pat',
|
||||
game: 'gam',
|
||||
userLibrary: 'ulb',
|
||||
gameDepot: 'gdp',
|
||||
gameDownload: 'gdl'
|
||||
} as const;
|
||||
|
||||
export function schema(prefix: keyof typeof prefixes) {
|
||||
return z.string().startsWith(prefixes[prefix]);
|
||||
}
|
||||
|
||||
const LENGTH = 26;
|
||||
|
||||
let lastTimestamp = 0;
|
||||
let counter = 0;
|
||||
|
||||
export function ascending(prefix: keyof typeof prefixes, given?: string) {
|
||||
return generateID(prefix, false, given);
|
||||
}
|
||||
|
||||
export function descending(prefix: keyof typeof prefixes, given?: string) {
|
||||
return generateID(prefix, true, given);
|
||||
}
|
||||
|
||||
function generateID(prefix: keyof typeof prefixes, descending: boolean, given?: string): string {
|
||||
if (!given) {
|
||||
return generateNewID(prefix, descending);
|
||||
}
|
||||
|
||||
if (!given.startsWith(prefixes[prefix])) {
|
||||
throw new Error(`ID ${given} does not start with ${prefixes[prefix]}`);
|
||||
}
|
||||
return given;
|
||||
}
|
||||
|
||||
function randomBase62(length: number): string {
|
||||
const chars = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz';
|
||||
let result = '';
|
||||
const bytes = randomBytes(length);
|
||||
for (let i = 0; i < length; i++) {
|
||||
result += chars[bytes[i]! % 62];
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function generateNewID(prefix: keyof typeof prefixes, descending: boolean): string {
|
||||
const currentTimestamp = Date.now();
|
||||
|
||||
if (currentTimestamp !== lastTimestamp) {
|
||||
lastTimestamp = currentTimestamp;
|
||||
counter = 0;
|
||||
}
|
||||
counter++;
|
||||
|
||||
let now = BigInt(currentTimestamp) * BigInt(0x1000) + BigInt(counter);
|
||||
|
||||
now = descending ? ~now : now;
|
||||
|
||||
const timeBytes = Buffer.alloc(6);
|
||||
for (let i = 0; i < 6; i++) {
|
||||
timeBytes[i] = Number((now >> BigInt(40 - 8 * i)) & BigInt(0xff));
|
||||
}
|
||||
|
||||
return prefixes[prefix] + '_' + timeBytes.toString('hex') + randomBase62(LENGTH - 12);
|
||||
}
|
||||
}
|
||||
261
packages/core/src/machine/index.ts
Normal file
261
packages/core/src/machine/index.ts
Normal file
@@ -0,0 +1,261 @@
|
||||
import { randomBytes } from 'node:crypto';
|
||||
|
||||
import { and, eq, isNull, sql } from 'drizzle-orm';
|
||||
import z from 'zod';
|
||||
|
||||
import { Database } from '../db/index.js';
|
||||
import { Examples } from '../examples.js';
|
||||
import { fn } from '../fn.js';
|
||||
import { Member } from '../team/member.js';
|
||||
import { MachineTable } from './machine.sql.js';
|
||||
|
||||
/**
|
||||
* Registered host identity.
|
||||
*
|
||||
* A box trades an owner-supplied token for an assigned id and a secret, then
|
||||
* authenticates as itself. The alternative — deriving an id from
|
||||
* `/etc/machine-id` or a hardware fingerprint — was rejected: self-hosted
|
||||
* boxes mean the operator is not automatically trusted, and every such input
|
||||
* is operator-editable, so uniqueness would rest on nobody choosing to lie.
|
||||
*/
|
||||
export namespace Machine {
|
||||
/** Length in bytes before base64url encoding. */
|
||||
const SECRET_BYTES = 32;
|
||||
|
||||
export const Info = z
|
||||
.object({
|
||||
id: z.string().meta({
|
||||
description: 'Unique identifier for the machine',
|
||||
example: Examples.Machine.id
|
||||
}),
|
||||
ownerUserId: z.string().meta({
|
||||
description: 'The user who registered this machine',
|
||||
example: Examples.Machine.ownerUserId
|
||||
}),
|
||||
teamId: z.string().optional().nullable().meta({
|
||||
description: 'The team this machine belongs to, when registered inside one',
|
||||
example: Examples.Machine.teamId
|
||||
}),
|
||||
label: z.string().meta({
|
||||
description: 'Human-readable name for the box',
|
||||
example: Examples.Machine.label
|
||||
}),
|
||||
lastSeen: z.iso.datetime().optional().nullable().meta({
|
||||
description: 'When this machine last authenticated',
|
||||
example: Examples.Machine.lastSeen
|
||||
})
|
||||
})
|
||||
.meta({
|
||||
ref: 'Machine',
|
||||
description: 'A registered nessh host',
|
||||
example: Examples.Machine
|
||||
});
|
||||
|
||||
export type Info = z.infer<typeof Info>;
|
||||
|
||||
function generateSecret(): string {
|
||||
return `msk_${randomBytes(SECRET_BYTES).toString('base64url')}`;
|
||||
}
|
||||
|
||||
async function hashSecret(secret: string): Promise<string> {
|
||||
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(secret));
|
||||
return Array.from(new Uint8Array(digest))
|
||||
.map((b) => b.toString(16).padStart(2, '0'))
|
||||
.join('');
|
||||
}
|
||||
|
||||
/** Length-independent, content-constant comparison of two hex digests. */
|
||||
function secureEquals(a: string, b: string): boolean {
|
||||
if (a.length !== b.length) {
|
||||
return false;
|
||||
}
|
||||
let diff = 0;
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
||||
}
|
||||
return diff === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a box. The secret is returned here and nowhere else — it is
|
||||
* stored only as a digest, so a lost secret means re-registering rather
|
||||
* than looking it up.
|
||||
*/
|
||||
export const register = fn(
|
||||
Info.pick({ id: true, ownerUserId: true, teamId: true, label: true }),
|
||||
async (input) => {
|
||||
const secret = generateSecret();
|
||||
await Database.use(async (tx) => {
|
||||
await tx.insert(MachineTable).values({
|
||||
id: input.id,
|
||||
ownerUserId: input.ownerUserId,
|
||||
teamId: input.teamId ?? null,
|
||||
label: input.label,
|
||||
secretHash: await hashSecret(secret),
|
||||
lastSeen: null
|
||||
});
|
||||
});
|
||||
return { id: input.id, secret };
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Resolve credentials to a machine, or `null`. Looks the row up by id and
|
||||
* then compares digests, so a wrong id and a wrong secret are refused the
|
||||
* same way and neither reveals which half was wrong.
|
||||
*/
|
||||
export const authenticate = fn(
|
||||
z.object({ id: z.string(), secret: z.string() }),
|
||||
async (input) => {
|
||||
return Database.use(async (tx) => {
|
||||
return tx
|
||||
.select()
|
||||
.from(MachineTable)
|
||||
.where(and(eq(MachineTable.id, input.id), isNull(MachineTable.timeDeleted)))
|
||||
.then(async (rows) => {
|
||||
const row = rows.at(0);
|
||||
if (!row) {
|
||||
return null;
|
||||
}
|
||||
if (!secureEquals(row.secretHash, await hashSecret(input.secret))) {
|
||||
return null;
|
||||
}
|
||||
// Serialized here, so `secretHash` never leaves this
|
||||
// function even in memory — the caller cannot leak what
|
||||
// it was never handed.
|
||||
return serialize(row);
|
||||
});
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Move a box into a team, or back out of one with `teamId: null`.
|
||||
*
|
||||
* Scoped to the owner in the query itself, so a machine belonging to
|
||||
* someone else is a miss rather than a permission check that could be
|
||||
* forgotten. Membership of the *target* team is the caller's to verify —
|
||||
* this function knows about machines, not about who belongs where.
|
||||
*
|
||||
* Deliberately not ownership transfer. Scoping keeps the same owner and
|
||||
* should be easy; handing a box to a different person should not be, and
|
||||
* is left to re-registration until renting makes it worth building.
|
||||
*/
|
||||
export const setTeam = fn(
|
||||
Info.pick({ id: true, ownerUserId: true, teamId: true }),
|
||||
async (input) => {
|
||||
return Database.use(async (tx) => {
|
||||
return tx
|
||||
.update(MachineTable)
|
||||
.set({ teamId: input.teamId ?? null })
|
||||
.where(
|
||||
and(
|
||||
eq(MachineTable.id, input.id),
|
||||
eq(MachineTable.ownerUserId, input.ownerUserId),
|
||||
isNull(MachineTable.timeDeleted)
|
||||
)
|
||||
)
|
||||
.returning()
|
||||
.then((rows) => {
|
||||
const row = rows.at(0);
|
||||
return row ? serialize(row) : null;
|
||||
});
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
export const touchLastSeen = fn(Info.shape.id, async (id) => {
|
||||
await Database.use(async (tx) => {
|
||||
await tx
|
||||
.update(MachineTable)
|
||||
.set({ lastSeen: sql`now()` })
|
||||
.where(eq(MachineTable.id, id));
|
||||
});
|
||||
});
|
||||
|
||||
export const fromID = fn(Info.shape.id, async (id) => {
|
||||
return Database.use(async (tx) => {
|
||||
return tx
|
||||
.select()
|
||||
.from(MachineTable)
|
||||
.where(and(eq(MachineTable.id, id), isNull(MachineTable.timeDeleted)))
|
||||
.then((rows) => {
|
||||
const row = rows.at(0);
|
||||
return row ? serialize(row) : null;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/** Why a user may — or may not — use a box. */
|
||||
export const Entitlement = z.object({
|
||||
entitled: z.boolean(),
|
||||
/** `owner`, `team`, or `none`. Present so a refusal can explain itself. */
|
||||
reason: z.enum(['owner', 'team', 'none'])
|
||||
});
|
||||
|
||||
export type Entitlement = z.infer<typeof Entitlement>;
|
||||
|
||||
/**
|
||||
* Whether a user may use a box.
|
||||
*
|
||||
* The whole access model in one function: a solo box (`teamId` null) is the
|
||||
* owner's alone, and a team-scoped box is open to that team. Multi-user
|
||||
* access is the paid tier, so this is the line the paywall sits on — worth
|
||||
* having exactly one implementation of.
|
||||
*
|
||||
* Membership is read live rather than cached in the machine row, so
|
||||
* removing someone from a team takes their box access with it and nobody
|
||||
* has to remember to revoke anything.
|
||||
*/
|
||||
export const entitlement = fn(
|
||||
z.object({ machineId: z.string(), userId: z.string() }),
|
||||
async (input): Promise<Entitlement> => {
|
||||
const machine = await fromID(input.machineId);
|
||||
if (!machine) {
|
||||
return { entitled: false, reason: 'none' };
|
||||
}
|
||||
if (machine.ownerUserId === input.userId) {
|
||||
return { entitled: true, reason: 'owner' };
|
||||
}
|
||||
if (!machine.teamId) {
|
||||
// A solo box. Nobody but the owner, whatever else is true.
|
||||
return { entitled: false, reason: 'none' };
|
||||
}
|
||||
const membership = await Member.findByTeamAndUser({
|
||||
teamId: machine.teamId,
|
||||
userId: input.userId
|
||||
});
|
||||
return membership ? { entitled: true, reason: 'team' } : { entitled: false, reason: 'none' };
|
||||
}
|
||||
);
|
||||
|
||||
export const listByOwner = fn(Info.shape.ownerUserId, async (ownerUserId) => {
|
||||
return Database.use(async (tx) => {
|
||||
return tx
|
||||
.select()
|
||||
.from(MachineTable)
|
||||
.where(and(eq(MachineTable.ownerUserId, ownerUserId), isNull(MachineTable.timeDeleted)))
|
||||
.orderBy(MachineTable.timeCreated)
|
||||
.then((rows) => rows.map(serialize));
|
||||
});
|
||||
});
|
||||
|
||||
export const remove = fn(Info.shape.id, async (id) => {
|
||||
await Database.use(async (tx) => {
|
||||
await tx
|
||||
.update(MachineTable)
|
||||
.set({ timeDeleted: sql`now()` })
|
||||
.where(eq(MachineTable.id, id));
|
||||
});
|
||||
});
|
||||
|
||||
export function serialize(input: typeof MachineTable.$inferSelect): z.infer<typeof Info> {
|
||||
return {
|
||||
id: input.id,
|
||||
ownerUserId: input.ownerUserId,
|
||||
teamId: input.teamId,
|
||||
label: input.label,
|
||||
lastSeen: input.lastSeen?.toISOString() ?? null
|
||||
};
|
||||
}
|
||||
}
|
||||
38
packages/core/src/machine/machine.sql.ts
Normal file
38
packages/core/src/machine/machine.sql.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { index, pgTable, text, uniqueIndex } from 'drizzle-orm/pg-core';
|
||||
|
||||
import { id, timestamps, ulid, utc } from '../db/types.js';
|
||||
import { UserTable } from '../user/user.sql.js';
|
||||
|
||||
/**
|
||||
* A registered nessh host — the *box* that runs downloads and serves SSH, not
|
||||
* the laptop someone connects from. (`nessh-tui-redesign-guide.md` §7.2 uses
|
||||
* "machine" for the other end of that connection; this table is the host end.)
|
||||
*
|
||||
* A box does not assert who it is. It registers once against an owner's token
|
||||
* and is handed an id and a secret, so ids are unique because the API assigns
|
||||
* them rather than because a self-reported string happened not to collide.
|
||||
*/
|
||||
export const MachineTable = pgTable(
|
||||
'machine',
|
||||
{
|
||||
...id,
|
||||
...timestamps,
|
||||
ownerUserId: ulid('owner_user_id')
|
||||
.notNull()
|
||||
.references(() => UserTable.id, { onDelete: 'cascade' }),
|
||||
// Set only when the box was registered by someone acting inside a team.
|
||||
// A personal box has no team, and requiring one would make registering
|
||||
// impossible for the single-operator case that self-hosting is.
|
||||
teamId: ulid('team_id'),
|
||||
label: text('label').notNull(),
|
||||
// The secret itself is returned exactly once, at registration, and never
|
||||
// stored: a leaked database must not yield working box credentials.
|
||||
secretHash: text('secret_hash').notNull(),
|
||||
lastSeen: utc('last_seen')
|
||||
},
|
||||
(t) => [
|
||||
uniqueIndex('machine_secret_hash_unique').on(t.secretHash),
|
||||
index('machine_owner_idx').on(t.ownerUserId),
|
||||
index('machine_team_idx').on(t.teamId)
|
||||
]
|
||||
);
|
||||
155
packages/core/src/pairing-code/index.ts
Normal file
155
packages/core/src/pairing-code/index.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import { randomBytes } from 'node:crypto';
|
||||
|
||||
import { eq, and, isNull, sql, gt } from 'drizzle-orm';
|
||||
import z from 'zod';
|
||||
|
||||
import { Database } from '../db/index.js';
|
||||
import { Examples } from '../examples.js';
|
||||
import { fn } from '../fn.js';
|
||||
import { PairingCodeTable } from './pairing-code.sql.js';
|
||||
|
||||
function generateCode(): string {
|
||||
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
|
||||
let code = '';
|
||||
const bytes = randomBytes(4);
|
||||
for (let i = 0; i < 4; i++) {
|
||||
code += chars[bytes[i]! % chars.length];
|
||||
}
|
||||
return `NESSH-${code}`;
|
||||
}
|
||||
|
||||
export namespace PairingCode {
|
||||
export const Info = z
|
||||
.object({
|
||||
id: z.string().meta({
|
||||
description: 'Unique identifier for the pairing code record',
|
||||
example: Examples.PairingCode.id
|
||||
}),
|
||||
code: z.string().meta({
|
||||
description: 'Human-readable pairing code (e.g. NESSH-7F2Q)',
|
||||
example: Examples.PairingCode.code
|
||||
}),
|
||||
targetUserId: z.string().meta({
|
||||
description: 'The user who generated this code',
|
||||
example: Examples.PairingCode.targetUserId
|
||||
}),
|
||||
newFingerprint: z.string().optional().nullable().meta({
|
||||
description: 'The fingerprint that was paired (set on claim)',
|
||||
example: Examples.PairingCode.newFingerprint
|
||||
}),
|
||||
expiresAt: z.iso.datetime().meta({
|
||||
description: 'When this code expires',
|
||||
example: Examples.PairingCode.expiresAt
|
||||
}),
|
||||
claimedAt: z.iso.datetime().optional().nullable().meta({
|
||||
description: 'When this code was claimed',
|
||||
example: Examples.PairingCode.claimedAt
|
||||
}),
|
||||
isClaimed: z.boolean().meta({
|
||||
description: 'Whether this code has been used',
|
||||
example: Examples.PairingCode.isClaimed
|
||||
})
|
||||
})
|
||||
.meta({
|
||||
ref: 'PairingCode',
|
||||
description: 'Ephemeral device pairing code for linking a new SSH key to an existing user',
|
||||
example: Examples.PairingCode
|
||||
});
|
||||
|
||||
export type Info = z.infer<typeof Info>;
|
||||
|
||||
export const create = fn(
|
||||
Info.pick({ id: true, targetUserId: true }).extend({
|
||||
ttlMinutes: z.number().default(10)
|
||||
}),
|
||||
async (input) => {
|
||||
const code = generateCode();
|
||||
await Database.use(async (tx) => {
|
||||
await tx.insert(PairingCodeTable).values({
|
||||
id: input.id,
|
||||
code,
|
||||
targetUserId: input.targetUserId,
|
||||
expiresAt: sql`now() + interval '${sql.raw(String(input.ttlMinutes))} minutes'`,
|
||||
isClaimed: false,
|
||||
newFingerprint: null,
|
||||
claimedAt: null
|
||||
});
|
||||
});
|
||||
return code;
|
||||
}
|
||||
);
|
||||
|
||||
export const claim = fn(
|
||||
Info.pick({ code: true }).extend({ fingerprint: z.string() }),
|
||||
async (input) => {
|
||||
return Database.use(async (tx) => {
|
||||
const row = await tx
|
||||
.select()
|
||||
.from(PairingCodeTable)
|
||||
.where(
|
||||
and(
|
||||
eq(PairingCodeTable.code, input.code),
|
||||
eq(PairingCodeTable.isClaimed, false),
|
||||
gt(PairingCodeTable.expiresAt, sql`now()`),
|
||||
isNull(PairingCodeTable.timeDeleted)
|
||||
)
|
||||
)
|
||||
.then((rows) => rows.at(0) ?? null);
|
||||
|
||||
if (!row) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// `.returning()` rather than handing back the row read before
|
||||
// the update: that row still says `isClaimed: false`, so a
|
||||
// caller inspecting it would see a code that is not yet used.
|
||||
return tx
|
||||
.update(PairingCodeTable)
|
||||
.set({
|
||||
isClaimed: true,
|
||||
newFingerprint: input.fingerprint,
|
||||
claimedAt: sql`now()`
|
||||
})
|
||||
.where(eq(PairingCodeTable.id, row.id))
|
||||
.returning()
|
||||
.then((rows) => {
|
||||
const claimed = rows.at(0);
|
||||
return claimed ? serialize(claimed) : null;
|
||||
});
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
export const listByUser = fn(Info.shape.targetUserId, async (targetUserId) => {
|
||||
return Database.use(async (tx) => {
|
||||
return tx
|
||||
.select()
|
||||
.from(PairingCodeTable)
|
||||
.where(
|
||||
and(eq(PairingCodeTable.targetUserId, targetUserId), isNull(PairingCodeTable.timeDeleted))
|
||||
)
|
||||
.orderBy(PairingCodeTable.timeCreated);
|
||||
});
|
||||
});
|
||||
|
||||
export const remove = fn(Info.shape.id, async (id) => {
|
||||
await Database.use(async (tx) => {
|
||||
await tx
|
||||
.update(PairingCodeTable)
|
||||
.set({ timeDeleted: sql`now()` })
|
||||
.where(eq(PairingCodeTable.id, id));
|
||||
});
|
||||
});
|
||||
|
||||
export function serialize(input: typeof PairingCodeTable.$inferSelect): z.infer<typeof Info> {
|
||||
return {
|
||||
id: input.id,
|
||||
code: input.code,
|
||||
targetUserId: input.targetUserId,
|
||||
newFingerprint: input.newFingerprint,
|
||||
expiresAt: input.expiresAt.toISOString(),
|
||||
claimedAt: input.claimedAt?.toISOString() ?? null,
|
||||
isClaimed: input.isClaimed
|
||||
};
|
||||
}
|
||||
}
|
||||
21
packages/core/src/pairing-code/pairing-code.sql.ts
Normal file
21
packages/core/src/pairing-code/pairing-code.sql.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { boolean, index, pgTable, text, uniqueIndex } from 'drizzle-orm/pg-core';
|
||||
|
||||
import { id, timestamps, utc } from '../db/types.js';
|
||||
|
||||
export const PairingCodeTable = pgTable(
|
||||
'pairing_code',
|
||||
{
|
||||
...id,
|
||||
...timestamps,
|
||||
code: text('code').notNull(),
|
||||
targetUserId: text('target_user_id').notNull(),
|
||||
newFingerprint: text('new_fingerprint'),
|
||||
expiresAt: utc('expires_at').notNull(),
|
||||
claimedAt: utc('claimed_at'),
|
||||
isClaimed: boolean('is_claimed').notNull().default(false)
|
||||
},
|
||||
(t) => [
|
||||
uniqueIndex('pairing_code_code_unique').on(t.code),
|
||||
index('pairing_code_target_user_idx').on(t.targetUserId)
|
||||
]
|
||||
);
|
||||
231
packages/core/src/steam/index.ts
Normal file
231
packages/core/src/steam/index.ts
Normal file
@@ -0,0 +1,231 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { Actor } from '../actor.js';
|
||||
import { Database } from '../db/index.js';
|
||||
import { ErrorCodes, VisibleError } from '../error.js';
|
||||
import { fn } from '../fn.js';
|
||||
import { Identifier } from '../id.js';
|
||||
import { Fingerprint } from '../user/fingerprint.js';
|
||||
import { User } from '../user/index.js';
|
||||
import { LinkedAccount } from '../user/linked-account.js';
|
||||
|
||||
const STEAM_ID_RE = /^\d{17}$/;
|
||||
|
||||
function isUniqueViolation(err: unknown): boolean {
|
||||
const e = err as { code?: string; cause?: { code?: string } };
|
||||
return e?.code === '23505' || e?.cause?.code === '23505';
|
||||
}
|
||||
|
||||
type SshIdentityInput = {
|
||||
fingerprint: string;
|
||||
steamId: string;
|
||||
username?: string;
|
||||
profile?: Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
async function resolveSshIdentityOnce(
|
||||
input: SshIdentityInput
|
||||
): Promise<{ userID: string; linkedAccountID: string }> {
|
||||
const steamLink = await LinkedAccount.findByProvider({
|
||||
provider: 'steam',
|
||||
providerAccountId: input.steamId
|
||||
});
|
||||
|
||||
const fingerprintRow = await Fingerprint.findByFingerprint(input.fingerprint);
|
||||
const sshLink = await LinkedAccount.findSshByFingerprint(input.fingerprint);
|
||||
|
||||
if (steamLink) {
|
||||
const canonicalUserID = steamLink.userId;
|
||||
|
||||
if (input.profile) {
|
||||
await LinkedAccount.updateProfile({ id: steamLink.id, profile: input.profile });
|
||||
}
|
||||
|
||||
if (!fingerprintRow) {
|
||||
// Case A: existing Steam account, new SSH fingerprint.
|
||||
await Fingerprint.create({
|
||||
id: Identifier.ascending('userFingerprint'),
|
||||
userId: canonicalUserID,
|
||||
fingerprint: input.fingerprint,
|
||||
name: input.username ?? null
|
||||
});
|
||||
} else if (fingerprintRow.userId === canonicalUserID) {
|
||||
// Case D: same canonical user.
|
||||
await Fingerprint.touchLastSeen(fingerprintRow.id);
|
||||
} else {
|
||||
// Case E: fingerprint currently owned by a different user (device migration).
|
||||
const oldSteam = await LinkedAccount.findSteamByUser(fingerprintRow.userId);
|
||||
if (oldSteam) {
|
||||
throw new VisibleError(
|
||||
'forbidden',
|
||||
ErrorCodes.Permission.FORBIDDEN,
|
||||
`SSH key is already linked to Steam account ${oldSteam.providerAccountId}; refusing to switch accounts`
|
||||
);
|
||||
}
|
||||
const otherLinks = (await LinkedAccount.listByUser(fingerprintRow.userId)).filter(
|
||||
(l) => l.provider !== 'ssh' || l.providerAccountId !== input.fingerprint
|
||||
);
|
||||
if (otherLinks.length > 0) {
|
||||
throw new VisibleError(
|
||||
'forbidden',
|
||||
ErrorCodes.Permission.FORBIDDEN,
|
||||
'SSH key belongs to a user with other identities; refusing to merge'
|
||||
);
|
||||
}
|
||||
await Fingerprint.repoint({ fingerprint: input.fingerprint, userId: canonicalUserID });
|
||||
if (sshLink) {
|
||||
await LinkedAccount.repoint({ id: sshLink.id, userId: canonicalUserID });
|
||||
}
|
||||
}
|
||||
|
||||
if (!sshLink) {
|
||||
await LinkedAccount.create({
|
||||
id: Identifier.ascending('linkedAccount'),
|
||||
userId: canonicalUserID,
|
||||
provider: 'ssh',
|
||||
providerAccountId: input.fingerprint,
|
||||
profile: null
|
||||
});
|
||||
}
|
||||
|
||||
return { userID: canonicalUserID, linkedAccountID: steamLink.id };
|
||||
}
|
||||
|
||||
if (fingerprintRow) {
|
||||
// Case B: new Steam account, existing fingerprint.
|
||||
const currentUserID = fingerprintRow.userId;
|
||||
const existingSteam = await LinkedAccount.findSteamByUser(currentUserID);
|
||||
if (existingSteam) {
|
||||
throw new VisibleError(
|
||||
'forbidden',
|
||||
ErrorCodes.Permission.FORBIDDEN,
|
||||
`User is already linked to Steam account ${existingSteam.providerAccountId}`
|
||||
);
|
||||
}
|
||||
await Fingerprint.touchLastSeen(fingerprintRow.id);
|
||||
|
||||
const newSteamLinkID = Identifier.ascending('linkedAccount');
|
||||
await LinkedAccount.create({
|
||||
id: newSteamLinkID,
|
||||
userId: currentUserID,
|
||||
provider: 'steam',
|
||||
providerAccountId: input.steamId,
|
||||
profile: input.profile ?? null
|
||||
});
|
||||
if (!sshLink) {
|
||||
await LinkedAccount.create({
|
||||
id: Identifier.ascending('linkedAccount'),
|
||||
userId: currentUserID,
|
||||
provider: 'ssh',
|
||||
providerAccountId: input.fingerprint,
|
||||
profile: null
|
||||
});
|
||||
}
|
||||
return { userID: currentUserID, linkedAccountID: newSteamLinkID };
|
||||
}
|
||||
|
||||
// Case C: new Steam account, new SSH fingerprint.
|
||||
const newUserID = Identifier.ascending('user');
|
||||
const displayName = input.username ?? `player_${input.fingerprint.slice(0, 8)}`;
|
||||
await User.create({
|
||||
id: newUserID,
|
||||
name: displayName,
|
||||
email: undefined,
|
||||
emailVerified: false,
|
||||
image: null
|
||||
});
|
||||
|
||||
await Fingerprint.create({
|
||||
id: Identifier.ascending('userFingerprint'),
|
||||
userId: newUserID,
|
||||
fingerprint: input.fingerprint,
|
||||
name: input.username ?? null
|
||||
});
|
||||
|
||||
await LinkedAccount.create({
|
||||
id: Identifier.ascending('linkedAccount'),
|
||||
userId: newUserID,
|
||||
provider: 'ssh',
|
||||
providerAccountId: input.fingerprint,
|
||||
profile: null
|
||||
});
|
||||
|
||||
const newSteamLinkID = Identifier.ascending('linkedAccount');
|
||||
await LinkedAccount.create({
|
||||
id: newSteamLinkID,
|
||||
userId: newUserID,
|
||||
provider: 'steam',
|
||||
providerAccountId: input.steamId,
|
||||
profile: input.profile ?? null
|
||||
});
|
||||
|
||||
return { userID: newUserID, linkedAccountID: newSteamLinkID };
|
||||
}
|
||||
|
||||
export namespace Steam {
|
||||
export const link = fn(
|
||||
z.object({
|
||||
steamId: z.string(),
|
||||
profile: z.record(z.string(), z.unknown()).nullable().optional(),
|
||||
userId: z.string().optional()
|
||||
}),
|
||||
async (input) => {
|
||||
return Database.transaction(async () => {
|
||||
const existing = await LinkedAccount.findByProvider({
|
||||
provider: 'steam',
|
||||
providerAccountId: input.steamId
|
||||
});
|
||||
if (existing) {
|
||||
return existing.id;
|
||||
}
|
||||
const actor = Actor.use();
|
||||
const uid =
|
||||
input.userId ??
|
||||
(actor.type === 'user' || actor.type === 'member' ? actor.properties.userID : undefined);
|
||||
if (!uid) {
|
||||
throw new VisibleError(
|
||||
'forbidden',
|
||||
ErrorCodes.Permission.INSUFFICIENT_PERMISSIONS,
|
||||
'Cannot link Steam account without a user ID'
|
||||
);
|
||||
}
|
||||
const id = Identifier.ascending('linkedAccount');
|
||||
await LinkedAccount.create({
|
||||
id,
|
||||
userId: uid,
|
||||
provider: 'steam',
|
||||
providerAccountId: input.steamId,
|
||||
profile: input.profile ?? null
|
||||
});
|
||||
return id;
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
export const resolveSshIdentity = fn(
|
||||
z.object({
|
||||
fingerprint: z.string().min(1),
|
||||
steamId: z.string().regex(STEAM_ID_RE, 'must be a 17-digit Steam ID'),
|
||||
username: z.string().optional(),
|
||||
profile: z.record(z.string(), z.unknown()).nullable().optional()
|
||||
}),
|
||||
async (input) => {
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
try {
|
||||
// eslint-disable-next-line
|
||||
return await Database.transaction(async () => resolveSshIdentityOnce(input));
|
||||
} catch (err) {
|
||||
if (isUniqueViolation(err) && attempt < 2) {
|
||||
continue;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
throw new VisibleError(
|
||||
'internal',
|
||||
ErrorCodes.Server.INTERNAL_ERROR,
|
||||
'Failed to resolve SSH identity'
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
184
packages/core/src/steam/resolve.test.ts
Normal file
184
packages/core/src/steam/resolve.test.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
import { afterAll, describe, expect, test } from 'bun:test';
|
||||
|
||||
import { testDb } from '../db/test.js';
|
||||
import { Identifier } from '../id.js';
|
||||
import { Fingerprint } from '../user/fingerprint.js';
|
||||
import { User } from '../user/index.js';
|
||||
import { LinkedAccount } from '../user/linked-account.js';
|
||||
import { Steam } from './index.js';
|
||||
|
||||
const sql = testDb();
|
||||
|
||||
const createdUserIDs: string[] = [];
|
||||
|
||||
function steamID(n: number): string {
|
||||
return String(76561197960287930n + BigInt(n));
|
||||
}
|
||||
|
||||
function fingerprint(n: number): string {
|
||||
return `aa:bb:cc:dd:ee:ff:00:11:22:33:44:55:66:77:88:${String(n).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
async function resolve(fpr: string, sid: string) {
|
||||
return Steam.resolveSshIdentity({ fingerprint: fpr, steamId: sid });
|
||||
}
|
||||
|
||||
async function cleanup() {
|
||||
if (createdUserIDs.length > 0) {
|
||||
await sql`delete from "user" where id in ${sql(createdUserIDs)}`;
|
||||
createdUserIDs.length = 0;
|
||||
}
|
||||
}
|
||||
|
||||
function track(userID: string) {
|
||||
createdUserIDs.push(userID);
|
||||
return userID;
|
||||
}
|
||||
|
||||
async function countSteamLinks(steamId: string): Promise<number> {
|
||||
const rows = await sql`
|
||||
select count(*)::int as n from linked_account
|
||||
where provider = 'steam' and provider_account_id = ${steamId}
|
||||
`;
|
||||
return rows[0]?.n;
|
||||
}
|
||||
|
||||
async function countFingerprints(fpr: string): Promise<number> {
|
||||
const rows =
|
||||
await sql`select count(*)::int as n from user_fingerprint where fingerprint = ${fpr}`;
|
||||
return rows[0]?.n;
|
||||
}
|
||||
|
||||
describe('Steam.resolveSshIdentity', () => {
|
||||
afterAll(async () => {
|
||||
await cleanup();
|
||||
await sql.end();
|
||||
});
|
||||
|
||||
test('1. new fingerprint + new Steam ID creates one user', async () => {
|
||||
await cleanup();
|
||||
const result = await resolve(fingerprint(1), steamID(1));
|
||||
|
||||
expect(result.userID).toMatch(/^usr_/);
|
||||
track(result.userID);
|
||||
|
||||
const user = await User.fromID(result.userID);
|
||||
expect(user).not.toBeNull();
|
||||
|
||||
const links = await LinkedAccount.listByUser(result.userID);
|
||||
expect(links.map((l) => l.provider).sort()).toEqual(['ssh', 'steam']);
|
||||
expect(await countFingerprints(fingerprint(1))).toBe(1);
|
||||
expect(await countSteamLinks(steamID(1))).toBe(1);
|
||||
});
|
||||
|
||||
test('2. same fingerprint + same Steam ID returns the same user', async () => {
|
||||
const first = await resolve(fingerprint(2), steamID(2));
|
||||
track(first.userID);
|
||||
const second = await resolve(fingerprint(2), steamID(2));
|
||||
|
||||
expect(second.userID).toBe(first.userID);
|
||||
expect(second.linkedAccountID).toBe(first.linkedAccountID);
|
||||
expect(await countFingerprints(fingerprint(2))).toBe(1);
|
||||
expect(await countSteamLinks(steamID(2))).toBe(1);
|
||||
});
|
||||
|
||||
test('3. new fingerprint + same Steam ID returns the first user', async () => {
|
||||
const first = await resolve(fingerprint(3), steamID(3));
|
||||
track(first.userID);
|
||||
const second = await resolve(fingerprint(4), steamID(3));
|
||||
|
||||
expect(second.userID).toBe(first.userID);
|
||||
expect(second.linkedAccountID).toBe(first.linkedAccountID);
|
||||
track(second.userID);
|
||||
expect(await countFingerprints(fingerprint(4))).toBe(1);
|
||||
expect(await countSteamLinks(steamID(3))).toBe(1);
|
||||
});
|
||||
|
||||
test('4. same fingerprint + different Steam ID is rejected', async () => {
|
||||
await resolve(fingerprint(5), steamID(5));
|
||||
expect(resolve(fingerprint(5), steamID(6))).rejects.toMatchObject({
|
||||
type: 'forbidden'
|
||||
});
|
||||
});
|
||||
|
||||
test('5. existing Steam ID reassigns a provisional fingerprint user', async () => {
|
||||
const canonical = await resolve(fingerprint(7), steamID(7));
|
||||
track(canonical.userID);
|
||||
|
||||
const provisionalUserID = track(Identifier.ascending('user'));
|
||||
await User.create({
|
||||
id: provisionalUserID,
|
||||
name: 'provisional',
|
||||
email: undefined,
|
||||
emailVerified: false,
|
||||
image: null
|
||||
});
|
||||
const fpr = fingerprint(8);
|
||||
await Fingerprint.create({
|
||||
id: Identifier.ascending('userFingerprint'),
|
||||
userId: provisionalUserID,
|
||||
fingerprint: fpr,
|
||||
name: null
|
||||
});
|
||||
await LinkedAccount.create({
|
||||
id: Identifier.ascending('linkedAccount'),
|
||||
userId: provisionalUserID,
|
||||
provider: 'ssh',
|
||||
providerAccountId: fpr,
|
||||
profile: null
|
||||
});
|
||||
|
||||
const result = await resolve(fpr, steamID(7));
|
||||
|
||||
expect(result.userID).toBe(canonical.userID);
|
||||
|
||||
const row = await Fingerprint.findByFingerprint(fpr);
|
||||
expect(row?.userId).toBe(canonical.userID);
|
||||
const sshLink = await LinkedAccount.findSshByFingerprint(fpr);
|
||||
expect(sshLink?.userId).toBe(canonical.userID);
|
||||
});
|
||||
|
||||
test('5b. reassignment is rejected when the provisional user has a different Steam account', async () => {
|
||||
await resolve(fingerprint(9), steamID(9));
|
||||
expect(resolve(fingerprint(9), steamID(10))).rejects.toMatchObject({
|
||||
type: 'forbidden'
|
||||
});
|
||||
});
|
||||
|
||||
test('6. two concurrent new fingerprints for one Steam ID create one Steam link', async () => {
|
||||
await cleanup();
|
||||
const [a, b] = await Promise.all([
|
||||
resolve(fingerprint(11), steamID(11)),
|
||||
resolve(fingerprint(12), steamID(11))
|
||||
]);
|
||||
|
||||
expect(a.userID).toBe(b.userID);
|
||||
track(a.userID);
|
||||
expect(await countSteamLinks(steamID(11))).toBe(1);
|
||||
expect(await countFingerprints(fingerprint(11))).toBe(1);
|
||||
expect(await countFingerprints(fingerprint(12))).toBe(1);
|
||||
});
|
||||
|
||||
test('7. malformed request without Steam ID is rejected', () => {
|
||||
expect(
|
||||
Steam.resolveSshIdentity.schema.safeParse({ fingerprint: fingerprint(13) }).success
|
||||
).toBe(false);
|
||||
expect(
|
||||
Steam.resolveSshIdentity.schema.safeParse({
|
||||
fingerprint: fingerprint(13),
|
||||
steamId: 'not-a-steam-id'
|
||||
}).success
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test('8. last-seen is updated on repeat login', async () => {
|
||||
const first = await resolve(fingerprint(14), steamID(14));
|
||||
track(first.userID);
|
||||
const before = await Fingerprint.findByFingerprint(fingerprint(14));
|
||||
expect(before?.lastSeen).toBeNull();
|
||||
await new Promise((r) => setTimeout(r, 25));
|
||||
await resolve(fingerprint(14), steamID(14));
|
||||
const after = await Fingerprint.findByFingerprint(fingerprint(14));
|
||||
expect(after?.lastSeen).not.toBeNull();
|
||||
});
|
||||
});
|
||||
143
packages/core/src/team/index.ts
Normal file
143
packages/core/src/team/index.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import { eq, and, isNull, sql } from 'drizzle-orm';
|
||||
import z from 'zod';
|
||||
|
||||
import { Actor } from '../actor.js';
|
||||
import { Database } from '../db/index.js';
|
||||
import { Examples } from '../examples.js';
|
||||
import { fn } from '../fn.js';
|
||||
import { Identifier } from '../id.js';
|
||||
import { TeamMemberTable } from './member.sql.js';
|
||||
import { TeamTable } from './team.sql.js';
|
||||
|
||||
export namespace Team {
|
||||
export const Info = z
|
||||
.object({
|
||||
id: z.string().meta({
|
||||
description: 'Unique identifier for the team record',
|
||||
example: Examples.Team.id
|
||||
}),
|
||||
name: z.string().meta({
|
||||
description: 'Display name of the team',
|
||||
example: Examples.Team.name
|
||||
}),
|
||||
slug: z.string().meta({
|
||||
description: 'URL-friendly unique slug for the team',
|
||||
example: Examples.Team.slug
|
||||
}),
|
||||
ownerId: z.string().meta({
|
||||
description: 'The user who owns/created this team',
|
||||
example: Examples.Team.ownerId
|
||||
}),
|
||||
billingEmail: z.email().nullable().optional().meta({
|
||||
description: 'Email address used for billing and invoices',
|
||||
example: Examples.Team.billingEmail
|
||||
}),
|
||||
plan: z.string().optional().meta({
|
||||
description: 'Current billing plan (free, pro, team, enterprise)',
|
||||
example: Examples.Team.plan
|
||||
}),
|
||||
subscriptionStatus: z.string().optional().meta({
|
||||
description: 'Current subscription status (active, past_due, canceled, etc.)',
|
||||
example: Examples.Team.subscriptionStatus
|
||||
}),
|
||||
metadata: z.record(z.string(), z.unknown()).nullable().optional().meta({
|
||||
description: 'Arbitrary metadata attached to the team',
|
||||
example: Examples.Team.metadata
|
||||
})
|
||||
})
|
||||
.meta({
|
||||
ref: 'Team',
|
||||
description:
|
||||
'A team/organization for collaboration and billing. Users join teams via memberships.',
|
||||
example: Examples.Team
|
||||
});
|
||||
|
||||
export type Info = z.infer<typeof Info>;
|
||||
|
||||
export const create = fn(Info.pick({ id: true, name: true, slug: true }), async (input) => {
|
||||
const ownerId = Actor.userID;
|
||||
await Database.use(async (tx) => {
|
||||
await tx.insert(TeamTable).values({
|
||||
id: input.id,
|
||||
name: input.name,
|
||||
slug: input.slug,
|
||||
ownerId
|
||||
});
|
||||
await tx.insert(TeamMemberTable).values({
|
||||
id: Identifier.ascending('teamMember'),
|
||||
teamId: input.id,
|
||||
userId: ownerId,
|
||||
role: 'owner'
|
||||
});
|
||||
});
|
||||
return input.id;
|
||||
});
|
||||
|
||||
export const fromID = fn(Info.shape.id, async (id) => {
|
||||
return Database.use(async (tx) => {
|
||||
return tx
|
||||
.select()
|
||||
.from(TeamTable)
|
||||
.where(and(eq(TeamTable.id, id), isNull(TeamTable.timeDeleted)))
|
||||
.then((rows) => rows.at(0) ?? null);
|
||||
});
|
||||
});
|
||||
|
||||
export const fromSlug = fn(Info.shape.slug, async (slug) => {
|
||||
return Database.use(async (tx) => {
|
||||
return tx
|
||||
.select()
|
||||
.from(TeamTable)
|
||||
.where(and(eq(TeamTable.slug, slug), isNull(TeamTable.timeDeleted)))
|
||||
.then((rows) => rows.at(0) ?? null);
|
||||
});
|
||||
});
|
||||
|
||||
export async function list() {
|
||||
return Database.use(async (tx) => {
|
||||
return tx
|
||||
.select()
|
||||
.from(TeamTable)
|
||||
.where(isNull(TeamTable.timeDeleted))
|
||||
.orderBy(TeamTable.timeCreated);
|
||||
});
|
||||
}
|
||||
|
||||
export const remove = fn(Info.shape.id, async (id) => {
|
||||
await Database.use(async (tx) => {
|
||||
await tx
|
||||
.update(TeamTable)
|
||||
.set({ timeDeleted: sql`now()` })
|
||||
.where(eq(TeamTable.id, id));
|
||||
});
|
||||
});
|
||||
|
||||
export const createPersonal = fn(z.object({ displayName: z.string() }), async (input) => {
|
||||
const baseSlug = input.displayName
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-|-$/g, '')
|
||||
.slice(0, 50);
|
||||
|
||||
const existing = await fromSlug(baseSlug);
|
||||
const slug = existing
|
||||
? `${baseSlug}-${String(Math.floor(Math.random() * 9999)).padStart(4, '0')}`
|
||||
: baseSlug;
|
||||
|
||||
const id = Identifier.ascending('team');
|
||||
return create({ id, name: `${input.displayName}'s Team`, slug });
|
||||
});
|
||||
|
||||
export function serialize(input: typeof TeamTable.$inferSelect): z.infer<typeof Info> {
|
||||
return {
|
||||
id: input.id,
|
||||
name: input.name,
|
||||
slug: input.slug,
|
||||
ownerId: input.ownerId,
|
||||
billingEmail: input.billingEmail,
|
||||
plan: input.plan,
|
||||
subscriptionStatus: input.subscriptionStatus,
|
||||
metadata: input.metadata
|
||||
};
|
||||
}
|
||||
}
|
||||
27
packages/core/src/team/member.sql.ts
Normal file
27
packages/core/src/team/member.sql.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { index, pgTable, pgEnum, uniqueIndex } from 'drizzle-orm/pg-core';
|
||||
|
||||
import { id, timestamps, ulid } from '../db/types.js';
|
||||
import { UserTable } from '../user/user.sql.js';
|
||||
import { TeamTable } from './team.sql.js';
|
||||
|
||||
export const TeamMemberRole = pgEnum('team_member_role', ['owner', 'admin', 'member']);
|
||||
|
||||
export const TeamMemberTable = pgTable(
|
||||
'team_member',
|
||||
{
|
||||
...id,
|
||||
...timestamps,
|
||||
teamId: ulid('team_id')
|
||||
.notNull()
|
||||
.references(() => TeamTable.id, { onDelete: 'cascade' }),
|
||||
userId: ulid('user_id')
|
||||
.notNull()
|
||||
.references(() => UserTable.id, { onDelete: 'cascade' }),
|
||||
role: TeamMemberRole('role').notNull().default('member')
|
||||
},
|
||||
(t) => [
|
||||
uniqueIndex('team_member_team_user_unique').on(t.teamId, t.userId),
|
||||
index('team_member_team_idx').on(t.teamId),
|
||||
index('team_member_user_idx').on(t.userId)
|
||||
]
|
||||
);
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user