diff --git a/documentation-v2/content/oauth/providers/lichess.md b/documentation-v2/content/oauth/providers/lichess.md new file mode 100644 index 000000000..c09e00e77 --- /dev/null +++ b/documentation-v2/content/oauth/providers/lichess.md @@ -0,0 +1,127 @@ +--- +order:: 0 +title: "Lichess" +description: "Learn about using the Lichess provider in Lucia OAuth integration" +--- + +OAuth integration for Lichess. Provider id is `lichess`. + +```ts +import { lichess } from "@lucia-auth/oauth/providers"; +import { auth } from "./lucia.js"; + +const lichessAuth = lichess(auth, config); +``` + +## `lichess()` + +```ts +const lichess: ( + auth: Auth, + config: { + clientId: string; + redirectUri: string; + scope?: string[]; + } +) => LichessProvider; +``` + +##### Parameter + +| name | type | description | optional | +| ------------------ | ------------------------------------ | --------------------------------------- | :------: | +| auth | [`Auth`](/reference/lucia-auth/auth) | Lucia instance | | +| config.clientId | `string` | client id - choose any unique client id | | +| config.redirectUri | `string` | redirect URI | | +| config.scope | `string[]` | an array of scopes | ✓ | + +##### Returns + +| type | description | +| ------------------------------------- | ---------------- | +| [`LichessProvider`](#lichessprovider) | Lichess provider | + +## Interfaces + +### `LichessProvider` + +Satisfies [`LichessProvider`](/reference/oauth/oauthprovider). + +#### `getAuthorizationUrl()` + +Returns the authorization url for user redirection, a state and PKCE code verifier. The state and code verifier should be stored in a cookie and validated on callback. + +```ts +const getAuthorizationUrl: () => Promise< + [url: URL, state: string, codeVerifier: string] +>; +``` + +##### Returns + +| name | type | description | +| -------------- | -------- | -------------------- | +| `url` | `URL` | authorization url | +| `state` | `string` | state parameter used | +| `codeVerifier` | `string` | PKCE code verifier | + +#### `validateCallback()` + +Validates the callback code. Requires the PKCE code verifier generated with `getAuthorizationUrl()`. + +```ts +const validateCallback: ( + code: string, + codeVerifier: string +) => Promise; +``` + +##### Parameter + +| name | type | description | +| -------------- | -------- | --------------------------------------------------------- | +| `code` | `string` | authorization code from callback | +| `codeVerifier` | `string` | PKCE code verifier generated with `getAuthorizationUrl()` | + +##### Returns + +| type | +| ------------------------------------- | +| [`LichessUserAuth`](#lichessuserauth) | + +##### Errors + +Request errors are thrown as [`OAuthRequestError`](/reference/oauth/interfaces#oauthrequesterror). + +### `LichessUserAuth` + +```ts +type LichessUserAuth = ProviderUserAuth & { + lichessUser: LichessUser; + lichessTokens: LichessTokens; +}; +``` + +| type | +| ------------------------------------------------------------------ | +| [`ProviderUserAuth`](/reference/oauth/interfaces#provideruserauth) | +| [`LichessUser`](#lichessuser) | +| [`LichessTokens`](#lichesstokens) | + +### `LichessTokens` + +```ts +type LichessTokens = { + accessToken: string; + accessTokenExpiresIn: string; +}; +``` + +## `LichessUser` + +```ts +type LichessUser = { + id: string; + username: string; +}; +``` diff --git a/documentation-v2/content/reference/oauth/providers.md b/documentation-v2/content/reference/oauth/providers.md index 99902e657..0cd02f9d9 100644 --- a/documentation-v2/content/reference/oauth/providers.md +++ b/documentation-v2/content/reference/oauth/providers.md @@ -24,6 +24,10 @@ See [Github](/oauth/providers/github) provider. See [Google](/oauth/providers/google) provider. +## `lichess()` + +See [Lichess](/oauth/providers/lichess) provider. + ## `linkedin()` See [Linkedin](/oauth/providers/linkedin) provider. diff --git a/packages/oauth/src/core.ts b/packages/oauth/src/core.ts index ad3713597..55cc80b74 100644 --- a/packages/oauth/src/core.ts +++ b/packages/oauth/src/core.ts @@ -38,6 +38,23 @@ export const generateState = () => { return generateRandomString(43); }; +export const encodeBase64 = (s: string) => { + // ORDER IS IMPORTANT!! + // Buffer API EXISTS IN DENO!! + if (typeof window !== "undefined" && "Deno" in window) { + // deno + return btoa(s); + } + if (typeof Buffer === "function") { + // node + return Buffer.from(s).toString("base64"); + } + + // standard API + // IGNORE WARNING + return btoa(s); +}; + export const scope = (base: string[], config: string[] = []) => { return [...base, ...(config ?? [])].join(" "); }; diff --git a/packages/oauth/src/providers/index.ts b/packages/oauth/src/providers/index.ts index 937ed382a..c1ab93b72 100644 --- a/packages/oauth/src/providers/index.ts +++ b/packages/oauth/src/providers/index.ts @@ -4,6 +4,7 @@ export { discord, type DiscordUser } from "./discord.js"; export { patreon, type PatreonUser } from "./patreon.js"; export { twitch, type TwitchUser } from "./twitch.js"; export { reddit, type RedditUser } from "./reddit.js"; +export { lichess, type LichessUser } from "./lichess.js"; export { linkedin, type LinkedinUser } from "./linkedin.js"; export { auth0, type Auth0User } from "./auth0.js"; export { facebook, type FacebookUser } from "./facebook.js"; diff --git a/packages/oauth/src/providers/lichess.ts b/packages/oauth/src/providers/lichess.ts new file mode 100644 index 000000000..9c62bfe25 --- /dev/null +++ b/packages/oauth/src/providers/lichess.ts @@ -0,0 +1,116 @@ +import { createUrl, handleRequest, authorizationHeaders } from "../request.js"; +import { + scope, + generateState, + providerUserAuth, + encodeBase64 +} from "../core.js"; +import { generateRandomString } from "lucia/utils"; + +import type { Auth } from "lucia"; +import type { OAuthConfig, OAuthProvider } from "../core.js"; + +type Config = OAuthConfig & { + redirectUri: string; +}; + +const PROVIDER_ID = "lichess"; + +export const lichess = <_Auth extends Auth>(auth: _Auth, config: Config) => { + const getAuthorizationUrl = async () => { + const state = generateState(); + // PKCE code verifier length and alphabet defined in RFC 7636 section 4.1 + const code_verifier = generateRandomString( + 96, + "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890-_.~" + ); + const url = createUrl("https://lichess.org/oauth", { + response_type: "code", + client_id: config.clientId, + code_challenge_method: "S256", + code_challenge: pkceBase64urlEncode( + await pkceCodeChallenge(code_verifier) + ), + scope: scope([], config.scope), + redirect_uri: config.redirectUri, + state + }); + return [url, state, code_verifier] as const; + }; + + const getLichessTokens = async (code: string, codeVerifier: string) => { + // Not using createUrl since we need to POST + const request = new Request("https://lichess.org/api/token", { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded" + }, + body: new URLSearchParams({ + client_id: config.clientId, + grant_type: "authorization_code", + redirect_uri: config.redirectUri, + code_verifier: codeVerifier, + code + }).toString() + }); + const tokens = await handleRequest<{ + access_token: string; + expires_in: number; + }>(request); + + return { + accessToken: tokens.access_token, + accessTokenExpiresIn: tokens.expires_in + }; + }; + + const getLichessUser = async (accessToken: string) => { + const request = new Request("https://lichess.org/api/account", { + headers: authorizationHeaders("bearer", accessToken) + }); + const lichessUser = await handleRequest(request); + return lichessUser; + }; + + const validateCallback = async (code: string, code_verifier: string) => { + const lichessTokens = await getLichessTokens(code, code_verifier); + const lichessUser = await getLichessUser(lichessTokens.accessToken); + const providerUserId = lichessUser.id; + const lichessUserAuth = await providerUserAuth( + auth, + PROVIDER_ID, + providerUserId + ); + return { + ...lichessUserAuth, + lichessUser, + lichessTokens + }; + }; + + return { + getAuthorizationUrl, + validateCallback + } as const satisfies OAuthProvider; +}; + +export type LichessUser = { + id: string; + username: string; +}; + +// Base64url-encode as specified in RFC 7636 (OAuth PKCE). +const pkceBase64urlEncode = (arg: string) => { + return encodeBase64(arg) + .split("=")[0] + .replace(/\+/g, "-") + .replace(/\//g, "_"); +}; + +// Generates code_challenge from code_verifier, as specified in RFC 7636. +const pkceCodeChallenge = async (verifier: string) => { + const verifierBuffer = new TextEncoder().encode(verifier); + const challengeBuffer = await crypto.subtle.digest("SHA-256", verifierBuffer); + const challengeArray = Array.from(new Uint8Array(challengeBuffer)); + return String.fromCharCode(...challengeArray); +}; diff --git a/packages/oauth/src/providers/reddit.ts b/packages/oauth/src/providers/reddit.ts index 0f8c26db0..cef00b7cf 100644 --- a/packages/oauth/src/providers/reddit.ts +++ b/packages/oauth/src/providers/reddit.ts @@ -1,5 +1,10 @@ import { createUrl, handleRequest, authorizationHeaders } from "../request.js"; -import { scope, generateState, providerUserAuth } from "../core.js"; +import { + scope, + generateState, + providerUserAuth, + encodeBase64 +} from "../core.js"; import type { Auth } from "lucia"; import type { OAuthConfig, OAuthProvider } from "../core.js"; @@ -73,23 +78,6 @@ export const reddit = <_Auth extends Auth>(auth: _Auth, config: Config) => { } as const satisfies OAuthProvider; }; -const encodeBase64 = (s: string) => { - // ORDER IS IMPORTANT!! - // Buffer API EXISTS IN DENO!! - if (typeof window !== "undefined" && "Deno" in window) { - // deno - return btoa(s); - } - if (typeof Buffer === "function") { - // node - return Buffer.from(s).toString("base64"); - } - - // standard API - // IGNORE WARNING - return btoa(s); -}; - export type RedditUser = { is_employee: boolean; seen_layout_switch: boolean;