diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml index 04a0672a..3188d80d 100644 --- a/.github/workflows/publish.yaml +++ b/.github/workflows/publish.yaml @@ -9,6 +9,7 @@ permissions: env: AURI_GITHUB_TOKEN: ${{secrets.AURI_GITHUB_TOKEN}} NODE_AUTH_TOKEN: ${{secrets.NODE_AUTH_TOKEN}} + CLOUDFLARE_API_TOKEN: ${{secrets.CLOUDFLARE_API_TOKEN_V2}} jobs: publish-package: @@ -75,3 +76,29 @@ jobs: - name: Deploy to GitHub Pages id: deployment uses: actions/deploy-pages@v1 + + publish-v2-docs: + name: Publish v2 docs + runs-on: ubuntu-latest + needs: check-changesets + if: needs.check-changesets.check-changesets.outputs.changesets == 0 && github.ref_name == 'v2' + steps: + - name: setup actions + uses: actions/checkout@v3 + - name: setup node + uses: actions/setup-node@v3 + with: + node-version: 20.5.1 + registry-url: https://registry.npmjs.org + - name: install malta + working-directory: docs + run: | + curl -o malta.tgz -L https://github.com/pilcrowonpaper/malta/releases/latest/download/linux-amd64.tgz + tar -xvzf malta.tgz + - name: build + working-directory: docs + run: ./linux-amd64/malta build + - name: install wrangler + run: npm i -g wrangler + - name: deploy + run: wrangler pages deploy docs/dist --project-name arctic-v2 --branch main diff --git a/CHANGELOG.md b/CHANGELOG.md index c8830382..0fd89872 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,140 +1,47 @@ # arctic -## 1.9.1 +## 2.0.0-next.5 -### Patch changes +- Export `decodeIdToken()` -- Fix: Remove new lines when parsing Apple certificate ([#139](https://github.com/pilcrowOnPaper/arctic/pull/139)) +## 2.0.0-next.4 -## 1.9.0 +- Fix `createAuthorizationURL()` methods. -### Minor changes +## 2.0.0-next.2 -- Added Authentik auth provider ([#120](https://github.com/pilcrowOnPaper/arctic/pull/120)) +## Major changes -## 1.8.1 +- Update `createAuthorizationURL()` provider methods +- Remove `SlackApp` and `SlackOpenID` -### Patch changes +## 2.0.0-next.1 -- Fix Slack provider ([#122](https://github.com/pilcrowOnPaper/arctic/pull/122)) -- Fix Okta provider ([#124](https://github.com/pilcrowOnPaper/arctic/pull/124)) +## Minor changes -## 1.8.0 +- Add KeyCloak provider -### Minor changes +## Patch changes -- Feat: Add `idToken` to the return value of LinkedIn's `validateAuthorizationCode(code: string)` ([#105](https://github.com/pilcrowOnPaper/arctic/pull/105)) -- Feat: Add Tiltify provider. ([#118](https://github.com/pilcrowOnPaper/arctic/pull/118)) +- Fix token endpoint initialization in `Salesforce` provider -### Patch changes +## 2.0.0-next.0 -- Fix: Make `refreshToken` optional for the return value of LinkedIn's `validateAuthorizationCode(code: string)` ([#105](https://github.com/pilcrowOnPaper/arctic/pull/105)) +## Major changes -## 1.7.0 +- `createAuthorizationURL()` no longer returns a `Promise` +- `validateAuthorizationCode()` and `refreshAccessToken()` returns `OAuth2Tokens` +- `validateAuthorizationCode()` and `refreshAccessToken()` can throw one of `OAuth2RequestError`, `ArcticFetchError`, or `Error` +- Scopes are no longer set by default, including `openid` and those required by the provider +- Updated parameters for `Apple`, `GitHub`, `GitLab`, `MicrosoftEntraId`, `MyAnimeList`, `Okta`, `Osu`, and `Salesforce` +- Removed `options.scope` parameter from `createAuthorizationURL()` +- Removed `OAuth2Provider` and `OAuth2ProviderWithPKCE` +- Replace `Slack` with `SlackApp` and `SlackOpenID` +- Remove `Keycloak` -### Minor changes +## Minor changes -- Add Shikimori provider. ([#95](https://github.com/pilcrowOnPaper/arctic/pull/95)) -- Feat: add 42 school provider ([#109](https://github.com/pilcrowOnPaper/arctic/pull/109)) - -## 1.6.2 - -### Patch changes - -- Use HTTP basic auth for sending client credentials if supported ([#113](https://github.com/pilcrowOnPaper/arctic/pull/113)) - -## 1.6.1 - -### Patch changes - -- Fix Roblox provider and reverted API changes introduced in 1.6.0 ([#111](https://github.com/pilcrowOnPaper/arctic/pull/111)) - -## 1.6.0 - -### Minor changes - -- Add Intuit provider. ([#97](https://github.com/pilcrowOnPaper/arctic/pull/97)) - -### Patch changes - -- Fix Roblox provider (see docs for API changes) ([#110](https://github.com/pilcrowOnPaper/arctic/pull/110)) - -## 1.5.0 - -### Minor changes - -- Add AniList provider. ([#92](https://github.com/pilcrowOnPaper/arctic/pull/92)) - -## 1.4.0 - -### Minor changes - -- Add MyAnimeList provider. ([#89](https://github.com/pilcrowOnPaper/arctic/pull/89)) -- Add Roblox provider. ([#88](https://github.com/pilcrowOnPaper/arctic/pull/88)) -- Add VK provider. ([#88](https://github.com/pilcrowOnPaper/arctic/pull/88)) - -### Patch changes - -- Update dependencies. ([#89](https://github.com/pilcrowOnPaper/arctic/pull/89)) - -## 1.3.0 - -### Minor changes - -- Add Yandex provider. ([#85](https://github.com/pilcrowOnPaper/arctic/pull/85)) -- Feat: Add support for Github Enterprise Server to `GitHub` Provider ([#77](https://github.com/pilcrowOnPaper/arctic/pull/77)) - -## 1.2.1 - -### Patch changes - -- Move `auri` to dev dependencies. ([#75](https://github.com/pilcrowOnPaper/arctic/pull/75)) - -## 1.2.0 - -### Minor changes - -- Add Dribbble provider ([#69](https://github.com/pilcrowOnPaper/arctic/pull/69)) - -### Patch changes - -- Fix: Export GitLab provider ([#73](https://github.com/pilcrowOnPaper/arctic/pull/73)) - -## 1.1.6 - -### Patch changes - -- Fix Atlassian refresh token method ([#67](https://github.com/pilcrowOnPaper/arctic/pull/67)) - -## 1.1.5 - -### Patch changes - -- Fix: Fix wrong refresh token expiration date in Keycloak provider ([#65](https://github.com/pilcrowOnPaper/arctic/pull/65)) - -## 1.1.4 - -### Patch changes - -- Fix: Fix spotify provider refresh token not being passed the credentials ([#60](https://github.com/pilcrowOnPaper/arctic/pull/60)) - -## 1.1.3 - -### Patch changes - -- Fix: Use request body for sending credentials in Dropbox provider ([#55](https://github.com/pilcrowOnPaper/arctic/pull/55)) - -## 1.1.0 - -- Add Patreon provider [#46](https://github.com/pilcrowOnPaper/arctic/pull/46) -- Add Amazon Cognito provider [#47](https://github.com/pilcrowOnPaper/arctic/pull/47) -- Add Strava provider [#48](https://github.com/pilcrowOnPaper/arctic/pull/48) -- Add osu! provider [#49](https://github.com/pilcrowOnPaper/arctic/pull/49) -- Add Zoom provider [#50](https://github.com/pilcrowOnPaper/arctic/pull/50) -- Add Linear provider [#51](https://github.com/pilcrowOnPaper/arctic/pull/51) -- Add Coinbase provider [#52](https://github.com/pilcrowOnPaper/arctic/pull/52) -- Add WorkOS provider [#53](https://github.com/pilcrowOnPaper/arctic/pull/53) - -## 1.0.1 - -- Fix Atlassian provider +- Add `refreshAccessToken()` to `GitHub` +- `createAuthorizationURL()` returns `AuthorizationCodeAuthorizationURL` +- Add `decodeIdToken()` +- Add token revocation API diff --git a/README.md b/README.md index bf76d505..73da8e08 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,10 @@ # Arctic -Arctic is an OAuth 2.0 library for JavaScript/TypeScript that supports numerous providers. It's light weight, fully-typed, and runtime-agnostic. [Read the documentation →](https://arctic.js.org) +Arctic is a collection of OAuth 2.0 clients for popular providers. It only supports the authorization code grant type and intended to be used server-side. Built on top of the Fetch API, it's light weight, fully-typed, and runtime-agnostic. + +``` +npm install arctic@next +``` ```ts import { GitHub, generateState } from "arctic"; @@ -8,16 +12,16 @@ import { GitHub, generateState } from "arctic"; const github = new GitHub(clientId, clientSecret); const state = generateState(); -const authorizationURL = await github.createAuthorizationURL(state, { - scopes: ["user:email"] -}); +const scopes = ["user:email"]; +const authorizationURL = github.createAuthorizationURL(state, scopes); + +// ... const tokens = await github.validateAuthorizationCode(code); +const accessToken = tokens.accessToken(); ``` -For a flexible OAuth 2.0 client, see [`oslo/oauth2`](http://github.com/pilcrowonpaper/oslo). - -> Arctic only supports providers that strictly follow the OAuth 2.0 spec (including PKCE). +> Arctic only supports providers that follow the OAuth 2.0 spec (including PKCE and token revocation). ## Semver @@ -40,12 +44,11 @@ Arctic does not strictly follow semantic versioning. While we aim to only introd - Dropbox - Facebook - Figma -- Github +- GitHub - GitLab - Google - Intuit - Kakao -- Keycloak - Lichess - Line - Linear diff --git a/docs/malta.config.json b/docs/malta.config.json index 799758ab..e0150572 100644 --- a/docs/malta.config.json +++ b/docs/malta.config.json @@ -1,15 +1,16 @@ { "name": "Arctic", - "description": "Arctic is a library that provides OAuth 2.0 clients for major providers.", + "description": "Arctic is a collection of OAuth 2.0 clients for popular providers.", "domain": "https://arctic.js.org", "twitter": "@pilcrowonpaper", + "asset_hashing": true, "sidebar": [ { "title": "Guides", "pages": [ ["OAuth 2.0", "/guides/oauth2"], ["OAuth 2.0 with PKCE", "/guides/oauth2-pkce"], - ["OpenID Connect", "/guides/oidc"] + ["Migrate to v2", "/guides/migrate-v2"] ] }, { @@ -35,7 +36,7 @@ ["Google", "/providers/google"], ["Intuit", "/providers/intuit"], ["Kakao", "/providers/kakao"], - ["Keycloak", "/providers/keycloak"], + ["KeyCloak", "/providers/keycloak"], ["Lichess", "/providers/lichess"], ["Line", "/providers/line"], ["Linear", "/providers/linear"], @@ -50,7 +51,7 @@ ["Roblox", "/providers/roblox"], ["Salesforce", "/providers/salesforce"], ["Shikimori", "/providers/shikimori"], - ["Slack", "/providers/slack"], + ["Slack", "/providers/slack-openid"], ["Spotify", "/providers/spotify"], ["Strava", "/providers/strava"], ["Tiltify", "/providers/tiltify"], @@ -64,6 +65,10 @@ ["Zoom", "/providers/zoom"] ] }, + { + "title": "API reference", + "pages": [["arctic", "/reference/main"]] + }, { "title": "Links", "pages": [ diff --git a/docs/pages/guides/migrate-v2.md b/docs/pages/guides/migrate-v2.md new file mode 100644 index 00000000..92f00e3a --- /dev/null +++ b/docs/pages/guides/migrate-v2.md @@ -0,0 +1,97 @@ +--- +title: "Migrate to v2" +--- + +# Migrate to v2 + +Arctic v2 is here! This update changes how tokens are handled and introduces various small improvements. Behind the scenes, it's also fully type-safe now! We used to heavily rely on type assertion but this upgrade adds proper `in` and `typeof` checks! + +``` +npm install arctic@next +``` + +> All providers except KeyCloak has been migrated. If you use keyCloak, please help us with the migration! + +## Authorization URL + +`createAuthorizationURL()` is no longer asynchronous and you can pass the scopes array directly. + +```ts +const scopes = ["user:email", "repo"]; +const url = github.createAuthorizationURL(state, scopes); +``` + +## Authorization code validation + +`validateAuthorizationCode()` returns an [`OAuth2Token`](/reference/main/OAuth2Token) instead of a simple object. To get the access token, call the `accessToken()` method. These methods will throw an error if the field doesn't exist. + +```ts +const tokens = await github.validateAuthorizationCode(code); +const accessToken = tokens.accessToken(); +const accessTokenExpiresAt = tokens.accessTokenExpiresAt(); +const refreshToken = tokens.refreshToken(); +const refreshTokenExpiresAt = tokens.refreshTokenExpiresAt(); +const idToken = tokens.idToken(); +``` + +Use `hasRefreshToken()` to check if the `refresh_token` field exists. + +```ts +if (tokens.hasRefreshToken()) { + const refreshToken = tokens.refreshToken(); + const refreshTokenExpiresAt = tokens.refreshTokenExpiresAt(); +} +``` + +`validateAuthorizationCode()` throws one of [`OAuth2RequestError`](/reference/main/OAuth2RequestError), [`ArcticFetchError`](/reference/main/ArcticFetchError), or `Error`. + +```ts +import { OAuth2RequestError, ArcticFetchError } from "arctic"; + +try { + const tokens = await github.validateAuthorizationCode(code); + const accessToken = tokens.accessToken(); +} catch (e) { + if (e instanceof OAuth2RequestError) { + // Invalid authorization code, credentials, or redirect URI + const code = e.code; + // ... + } + if (e instanceof ArcticFetchError) { + // Failed to call `fetch()` + const cause = e.cause; + // ... + } + // Parse error +} +``` + +## OpenID Connect + +Providers no longer include the `openid` scope by default. + +```ts +const scopes = ["openid", "profile"]; +const url = google.createAuthorizationURL(state, codeVerifier, scopes); +``` + +## Initialization + +The initialization parameters have changed for a few providers. See each provider's guide for details. + +- [Apple](/providers/apple) +- [GitHub](/proviGders/github) +- [GitLab](/providers/gitlab) +- [Microsoft Entra ID](/providers/microsoft-entra-id) +- [MyAnimeList](/providers/myanimelist) +- [Okta](/providers/okta) +- [osu!](/providers/osu) +- [Salesforce](/providers/salesforce) + +## Token revocation + +Token revocation API has been added for providers that support it. + +```ts +await google.revokeToken(token); +``` diff --git a/docs/pages/guides/oauth2-pkce.md b/docs/pages/guides/oauth2-pkce.md index d35aee92..5aa89de7 100644 --- a/docs/pages/guides/oauth2-pkce.md +++ b/docs/pages/guides/oauth2-pkce.md @@ -4,7 +4,7 @@ title: "OAuth 2.0 with PKCE" # OAuth 2.0 with PKCE -Most providers require a client ID, client secret, and redirect URI. +Most providers require a client ID, client secret, and redirect URI. The API is nearly identical across providers but always check each provider's guide before implementing. ```ts import { Google } from "arctic"; @@ -16,17 +16,15 @@ const google = new Google(clientId, clientSecret, redirectURI); Generate a state and code verifier using `generateState()` and `generateCodeVerifier()`. Use them to create an authorization URL with `createAuthorizationURL()`, store the state and code verifier as cookies, and redirect the user to the authorization url. -You may optionally pass `scopes`. For providers that implement OpenID Connect, `openid` is always included. There may be more options depending on the provider. - ```ts -import { generateCodeVerifier, generateState } from "arctic"; +import { generateState, generateCodeVerifier } from "arctic"; const state = generateState(); const codeVerifier = generateCodeVerifier(); +const scopes = ["user:email", "repo"]; +const url = google.createAuthorizationURL(state, codeVerifier, scopes); -const url = await google.createAuthorizationURL(state, codeVerifier); - -// store state verifier as cookie +// store state as cookie setCookie("state", state, { secure: true, // set to false in localhost path: "/", @@ -47,10 +45,10 @@ return redirect(url); ## Validate authorization code -Compare the state, and use `validateAuthorizationCode()` to validate the authorization code with the code verifier. This returns an object with an access token, an ID token for OIDC, and a refresh token if requested. If the code is invalid, it will throw an [`OAuth2RequestError`](https://oslo.js.org/reference/oauth2/OAuth2RequestError). +Compare the state, and use `validateAuthorizationCode()` to validate the authorization code and code verifier. This returns an [`OAuth2Tokens`](/reference/main/OAuth2Tokens), or throw one of [`OAuth2RequestError`](/reference/main/OAuth2RequestError), [`ArcticFetchError`](/reference/main/ArcticFetchError), or a standard `Error` (parse errors). ```ts -import { OAuth2RequestError } from "arctic"; +import { OAuth2RequestError, ArcticFetchError } from "arctic"; const code = request.url.searchParams.get("code"); const state = request.url.searchParams.get("state"); @@ -58,34 +56,43 @@ const state = request.url.searchParams.get("state"); const storedState = getCookie("state"); const storedCodeVerifier = getCookie("code_verifier"); -if (!code || !storedState || !storedCodeVerifier || state !== storedState) { +if (code === null || storedState === null || state !== storedState || storedCodeVerifier === null) { // 400 throw new Error("Invalid request"); } try { const tokens = await google.validateAuthorizationCode(code, storedCodeVerifier); + const accessToken = tokens.accessToken(); } catch (e) { if (e instanceof OAuth2RequestError) { - const { request, message, description } = e; + // Invalid authorization code, credentials, or redirect URI + const code = e.code; + // ... + } + if (e instanceof ArcticFetchError) { + // Failed to call `fetch()` + const cause = e.cause; + // ... } - // unknown error + // Parse error } ``` -## Refresh access token - -If the OAuth provider supports refresh tokens, `refreshAccessToken()` can be used to get a new access token using a refresh token. This will throw an [`OAuth2RequestError`](https://oslo.js.org/reference/oauth2/OAuth2RequestError) if the refresh token is invalid. +Calling `OAuth2Tokens.accessToken()` for example parses the response and returns the `access_token` field. If it doesn't exist, it will throw a parse `Error`. See each provider's guides for the actual return values. ```ts -import { OAuth2RequestError } from "arctic"; +const accessToken = tokens.accessToken(); +const accessTokenExpiresInSeconds = tokens.accessTokenExpiresInSeconds(); +const accessTokenExpiresAt = tokens.accessTokenExpiresAt(); +const refreshToken = tokens.refreshToken(); +const refreshTokenExpiresInSeconds = tokens.refreshTokenExpiresInSeconds(); +const refreshTokenExpiresAt = tokens.refreshTokenExpiresAt(); +const idToken = tokens.idToken(); +``` -try { - const tokens = await google.refreshAccessToken(refreshToken); -} catch (e) { - if (e instanceof OAuth2RequestError) { - const { request, message, description } = e; - } - // unknown error -} +Arctic provides [`decodeIdToken()`](/reference/main/decodeIdToken) for decoding the token's payload. + +```ts +const claims = decodeIdToken(idToken); ``` diff --git a/docs/pages/guides/oauth2.md b/docs/pages/guides/oauth2.md index 1e10a8d6..7646719a 100644 --- a/docs/pages/guides/oauth2.md +++ b/docs/pages/guides/oauth2.md @@ -4,28 +4,25 @@ title: "OAuth 2.0" # OAuth 2.0 -Most providers require a client ID, client secret, and redirect URI. +Most providers require a client ID, client secret, and redirect URI. The API is nearly identical across providers but always check each provider's guide before implementing. ```ts -import { Apple } from "arctic"; +import { GitHub } from "arctic"; -const apple = new Apple(clientId, clientSecret, redirectURI); +const github = new GitHub(clientId, clientSecret, redirectURI); ``` ## Create authorization URL Generate state using `generateState()` and store it as a cookie. Use it to create an authorization URL with `createAuthorizationURL()` and redirect the user to it. -You may optionally pass `scopes`. For providers that implement OpenID Connect, `openid` is always included. There may be more options depending on the provider. - ```ts import { generateState } from "arctic"; const state = generateState(); -const url = await github.createAuthorizationURL(state, { - scopes: ["user:email"] -}); +const scopes = ["user:email", "repo"]; +const url = github.createAuthorizationURL(state, scopes); // store state as cookie setCookie("state", state, { @@ -34,49 +31,59 @@ setCookie("state", state, { httpOnly: true, maxAge: 60 * 10 // 10 min }); + return redirect(url); ``` ## Validate authorization code -Compare the state, and use `validateAuthorizationCode()` to validate the authorization code. This returns an object with an access token, ID token for OIDC, and a refresh token if requested. If the code or your credentials are invalid, it will throw an [`OAuth2RequestError`](https://oslo.js.org/reference/oauth2/OAuth2RequestError). +Compare the state, and use `validateAuthorizationCode()` to validate the authorization code. This returns an [`OAuth2Tokens`](/reference/main/OAuth2Tokens), or throw one of [`OAuth2RequestError`](/reference/main/OAuth2RequestError), [`ArcticFetchError`](/reference/main/ArcticFetchError), or a standard `Error` (parse errors). ```ts -import { OAuth2RequestError } from "arctic"; +import { OAuth2RequestError, ArcticFetchError } from "arctic"; const code = request.url.searchParams.get("code"); const state = request.url.searchParams.get("state"); const storedState = getCookie("state"); -if (!code || !storedState || state !== storedState) { +if (code === null || storedState === null || state !== storedState) { // 400 throw new Error("Invalid request"); } try { const tokens = await github.validateAuthorizationCode(code); + const accessToken = tokens.accessToken(); } catch (e) { if (e instanceof OAuth2RequestError) { - const { message, description, request } = e; + // Invalid authorization code, credentials, or redirect URI + const code = e.code; + // ... + } + if (e instanceof ArcticFetchError) { + // Failed to call `fetch()` + const cause = e.cause; + // ... } - // unknown error + // Parse error } ``` -## Refresh access token - -If the OAuth provider supports refresh tokens, `refreshAccessToken()` can be used to get a new access token using a refresh token. This will throw an [`OAuth2RequestError`](https://oslo.js.org/reference/oauth2/OAuth2RequestError) if the refresh token is invalid. +Calling `OAuth2Tokens.accessToken()` for example parses the response and returns the `access_token` field. If it doesn't exist, it will throw a parse `Error`. See each provider's guides for the actual return values. ```ts -import { OAuth2RequestError } from "arctic"; +const accessToken = tokens.accessToken(); +const accessTokenExpiresInSeconds = tokens.accessTokenExpiresInSeconds(); +const accessTokenExpiresAt = tokens.accessTokenExpiresAt(); +const refreshToken = tokens.refreshToken(); +const refreshTokenExpiresInSeconds = tokens.refreshTokenExpiresInSeconds(); +const refreshTokenExpiresAt = tokens.refreshTokenExpiresAt(); +const idToken = tokens.idToken(); +``` -try { - const tokens = await google.refreshAccessToken(refreshToken); -} catch (e) { - if (e instanceof OAuth2RequestError) { - const { request, message, description } = e; - } - // unknown error -} +Arctic provides [`decodeIdToken()`](/reference/main/decodeIdToken) for decoding the token's payload. + +```ts +const claims = decodeIdToken(idToken); ``` diff --git a/docs/pages/guides/oidc.md b/docs/pages/guides/oidc.md deleted file mode 100644 index 211cbf4c..00000000 --- a/docs/pages/guides/oidc.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -title: "OpenID Connect" ---- - -# OpenID Connect - -Arctic will use OpenID Connect if the provider supports it. `validateAuthorizationCode()` will return an ID token for OIDC providers, which can be parsed to get the user info. - -```ts -import { parseJWT } from "oslo/jwt"; - -const tokens = await google.validateAuthorizationCode(code, codeVerifier); -const googleUser = parseJWT(tokens.idToken)!.payload; -``` diff --git a/docs/pages/index.md b/docs/pages/index.md index 8f2ccccc..6f03efcd 100644 --- a/docs/pages/index.md +++ b/docs/pages/index.md @@ -4,14 +4,31 @@ title: "Arctic documentation" # Arctic documentation -Arctic is a TypeScript library that provides OAuth 2.0 and OpenID Connect clients for major providers. It runs on any runtime, including Node.js, Bun, Deno, and Cloudflare Workers. +**This is the v2 docs!** -If you need a generic OAuth 2.0 client, see [Oslo](https://oslo.js.org). +Arctic is a collection of OAuth 2.0 clients for popular providers. It only supports the authorization code grant type and intended to be used server-side. Built on top of the Fetch API, it's light weight, fully-typed, and runtime-agnostic. + +```ts +import { GitHub, generateState } from "arctic"; + +const github = new GitHub(clientId, clientSecret); + +const state = generateState(); +const scopes = ["user:email"]; +const authorizationURL = github.createAuthorizationURL(state, scopes); + +// ... + +const tokens = await github.validateAuthorizationCode(code); +const accessToken = tokens.accessToken(); +``` + +> Arctic only supports providers that follow the OAuth 2.0 spec (including PKCE and token revocation). ## Installation ``` -npm install arctic +npm install arctic@next ``` ### Polyfill diff --git a/docs/pages/providers/42.md b/docs/pages/providers/42.md index eae43f99..a0f9d17a 100644 --- a/docs/pages/providers/42.md +++ b/docs/pages/providers/42.md @@ -4,35 +4,65 @@ title: "42 School" # 42 School -For usage, see [OAuth 2.0 provider](/guides/oauth2). +OAuth 2.0 provider for 42 School. -If you haven't done so already, you can create a new application at [42 Applications](https://profile.intra.42.fr/oauth/applications). Your application will receive an App UID and an App SECRET, both of which need to be provided. You'll also need to configure a `redirectURI` that matches the route in your application. +Also see the [OAuth 2.0](/guides/oauth2) guide. + +## Initialization + +`FortyTwo` takes a client ID, client secret, and redirect URI. ```ts -import { FortyTwo, FortyTwoTokens } from "arctic"; +import { FortyTwo } from "arctic"; -const ft = new FortyTwo(clientId, clientSecret, redirectURI); +const fortyTwo = new FortyTwo(clientId, clientSecret, redirectURI); ``` +## Create authorization URL + ```ts -const url: URL = await ft.createAuthorizationURL(state, { - // optional - scopes -}); -const tokens: FortyTwoTokens = await ft.validateAuthorizationCode(code); +import { generateState } from "arctic"; + +const state = generateState(); +const scopes = ["public", "projects"]; +const url = fortyTwo.createAuthorizationURL(state, scopes); +``` + +## Validate authorization code + +`validateAuthorizationCode()` will either return an [`OAuth2Tokens`](/reference/main/OAuth2Tokens), or throw one of [`OAuth2RequestError`](/reference/main/OAuth2RequestError), [`ArcticFetchError`](/reference/main/ArcticFetchError), or a standard `Error` (parse errors). 42 School will return an access token with an expiration. + +```ts +import { OAuth2RequestError, ArcticFetchError } from "arctic"; + +try { + const tokens = await fortyTwo.validateAuthorizationCode(code); + const accessToken = tokens.accessToken(); + const accessToken = tokens.accessTokenExpiresAt(); +} catch (e) { + if (e instanceof OAuth2RequestError) { + // Invalid authorization code, credentials, or redirect URI + const code = e.code; + // ... + } + if (e instanceof ArcticFetchError) { + // Failed to call `fetch()` + const cause = e.cause; + // ... + } + // Parse error +} ``` ## Get user profile -You can retrieve user information via the [`/v2/me` endpoint](https://api.intra.42.fr/v2/me). +You can retrieve user information via the [`/v2/me` endpoint](https://api.intra.42.fr/apidoc/2.0/users/me.html). ```ts const response = await fetch("https://api.intra.42.fr/v2/me", { headers: { - Authorization: `Bearer ${tokens.accessToken}` + Authorization: `Bearer ${accessToken}` } }); const user = await response.json(); ``` - -Refer to the [`/v2/me` documentation](https://api.intra.42.fr/apidoc/2.0/users/me.html) to see which fields are available. diff --git a/docs/pages/providers/amazon-cognito.md b/docs/pages/providers/amazon-cognito.md index 51297ae0..eb83b4d3 100644 --- a/docs/pages/providers/amazon-cognito.md +++ b/docs/pages/providers/amazon-cognito.md @@ -4,39 +4,132 @@ title: "Amazon Cognito" # Amazon Cognito -Implements OpenID Connect. +OAuth 2.0 authorization code provider for Amazon Cognito. -For usage, see [OAuth 2.0 provider with PKCE](/guides/oauth2-pkce). +Also see [OAuth 2.0 with PKCE](/guides/oauth2-pkce). + +## Initialization + +The user pool should not include trailing slashes. ```ts import { AmazonCognito } from "arctic"; -const userPoolDomain = "https://example.auth.region.amazoncognito.com"; -const amazonCognito = new AmazonCognito(userPoolDomain, clientId, clientSecret, redirectURI); +const userPool = "https://cognito-idp.{region}.amazonaws.com/{pool-id}"; +const cognito = new AmazonCognito(userPool, clientId, clientSecret, redirectURI); ``` +## Create authorization URL + ```ts -const url: URL = await amazonCognito.createAuthorizationURL(state, codeVerifier, { - // optional - scopes // "openid" always included -}); -const tokens: AmazonCognitoTokens = await amazonCognito.validateAuthorizationCode( - code, - codeVerifier -); -const tokens: AmazonCognitoRefreshedTokens = await amazonCognito.refreshAccessToken(refreshToken); +import { generateState, generateCodeVerifier } from "arctic"; + +const state = generateState(); +const codeVerifier = generateCodeVerifier(); +const scopes = ["openid", "profile"]; +const url = cognito.createAuthorizationURL(state, codeVerifier, scopes); +``` + +## Validate authorization code + +`validateAuthorizationCode()` will either return an [`OAuth2Tokens`](/reference/main/OAuth2Tokens), or throw one of [`OAuth2RequestError`](/reference/main/OAuth2RequestError), [`ArcticFetchError`](/reference/main/ArcticFetchError), or a standard `Error` (parse errors). Cognito returns an access token, the access token expiration, and a refresh token. + +```ts +import { OAuth2RequestError, ArcticFetchError } from "arctic"; + +try { + const tokens = await cognito.validateAuthorizationCode(code, codeVerifier); + const accessToken = tokens.accessToken(); + const accessTokenExpiresAt = tokens.accessTokenExpiresAt(); + const refreshToken = tokens.refreshToken(); +} catch (e) { + if (e instanceof OAuth2RequestError) { + // Invalid authorization code, credentials, or redirect URI + const code = e.code; + // ... + } + if (e instanceof ArcticFetchError) { + // Failed to call `fetch()` + const cause = e.cause; + // ... + } + // Parse error +} +``` + +## Refresh access tokens + +Use `refreshAccessToken()` to get a new access token using a refresh token. This method's behavior is identical to `validateAuthorizationCode()`. Cognito will only return a new access token. + +```ts +import { OAuth2RequestError, ArcticFetchError } from "arctic"; + +try { + const tokens = await cognito.refreshAccessToken(accessToken); + const accessToken = tokens.accessToken(); + const accessTokenExpiresAt = tokens.accessTokenExpiresAt(); +} catch (e) { + if (e instanceof OAuth2RequestError) { + // Invalid authorization code, credentials, or redirect URI + } + if (e instanceof ArcticFetchError) { + // Failed to call `fetch()` + } + // Parse error +} ``` -## Get user profile +## OpenID Connect -Parse the ID token or use the `userinfo` endpoint. See [sample ID token claims](https://openid.net/specs/openid-connect-core-1_0.html#StandardClaims). +Use OpenID Connect with the `openid` scope to get the user's profile with an ID token or the `userinfo` endpoint. Arctic provides [`decodeIdToken()`](/reference/main/decodeIdToken) for decoding the token's payload. ```ts -const tokens = await amazonCognito.validateAuthorizationCode(code, codeVerifier); -const response = await fetch(userPoolDomain + "/oauth/userInfo", { +const scopes = ["openid"]; +const url = cognito.createAuthorizationURL(state, codeVerifier, scopes); +``` + +```ts +import { decodeIdToken } from "arctic"; + +const tokens = await cognito.validateAuthorizationCode(code, codeVerifier); +const idToken = tokens.idToken(); +const claims = decodeIdToken(idToken); +``` + +```ts +const response = await fetch(userPool + "/oauth/userInfo", { headers: { - Authorization: `Bearer ${tokens.accessToken}` + Authorization: `Bearer ${accessToken}` } }); const user = await response.json(); ``` + +### Get user profile + +Make sure to add the `profile` scope to get the user profile and the `email` scope to get the user email. + +```ts +const scopes = ["openid", "profile", "email"]; +const url = cognito.createAuthorizationURL(state, codeVerifier, scopes); +``` + +## Revoke tokens + +Pass a token to `revokeToken()` to revoke all tokens associated with the authorization. This can throw the same errors as `validateAuthorizationCode()`. + +```ts +try { + await cognito.revokeToken(refreshToken); +} catch (e) { + if (e instanceof OAuth2RequestError) { + // Invalid authorization code, credentials, or redirect URI + } + if (e instanceof ArcticFetchError) { + // Failed to call `fetch()` + } + // Parse error +} +``` + +> Token revocation must be enabled in the settings. diff --git a/docs/pages/providers/anilist.md b/docs/pages/providers/anilist.md index 21bbd829..186fcd7c 100644 --- a/docs/pages/providers/anilist.md +++ b/docs/pages/providers/anilist.md @@ -4,15 +4,48 @@ title: "AniList" # AniList -For usage, see [OAuth 2.0 provider](/guides/oauth2). +OAuth 2.0 provider for AniList. + +Also see the [OAuth 2.0](/guides/oauth2) guide. + +## Initialization ```ts import { AniList } from "arctic"; -const anilist = new AniList(clientId, clientSecret, redirectURI); +const aniList = new AniList(clientId, clientSecret, redirectURI); +``` + +## Create authorization URL + +```ts +import { generateState } from "arctic"; + +const state = generateState(); +const url = aniList.createAuthorizationURL(state); ``` +## Validate authorization code + +`validateAuthorizationCode()` will either return an [`OAuth2Tokens`](/reference/main/OAuth2Tokens), or throw one of [`OAuth2RequestError`](/reference/main/OAuth2RequestError), [`ArcticFetchError`](/reference/main/ArcticFetchError), or a standard `Error` (parse errors). AniList will only return an access token (no expiration). + ```ts -const url: URL = await anilist.createAuthorizationURL(state); -const tokens: AniListTokens = await anilist.validateAuthorizationCode(code); +import { OAuth2RequestError, ArcticFetchError } from "arctic"; + +try { + const tokens = await aniList.validateAuthorizationCode(code); + const accessToken = tokens.accessToken(); +} catch (e) { + if (e instanceof OAuth2RequestError) { + // Invalid authorization code, credentials, or redirect URI + const code = e.code; + // ... + } + if (e instanceof ArcticFetchError) { + // Failed to call `fetch()` + const cause = e.cause; + // ... + } + // Parse error +} ``` diff --git a/docs/pages/providers/apple.md b/docs/pages/providers/apple.md index 3945877b..498ef2b8 100644 --- a/docs/pages/providers/apple.md +++ b/docs/pages/providers/apple.md @@ -4,86 +4,85 @@ title: "Apple" # Apple -Supported scopes are `email` and `name`. +OAuth 2.0 provider for Apple. -For usage, see [OAuth 2.0 provider](/guides/oauth2). +Also see the [OAuth 2.0](/guides/oauth2) guide. -```ts -import { Apple } from "arctic"; - -import type { AppleCredentials } from "arctic"; +## Initialization -const credentials: AppleCredentials = { - clientId, - teamId, - keyId, - certificate -}; - -const apple = new Apple(credentials, redirectURI); -``` +The PKCS#8 private key is an instance of `Uint8Array`. ```ts -const url: URL = await apple.createAuthorizationURL(state, { - // optional - scopes -}); -const tokens: AppleTokens = await apple.validateAuthorizationCode(code); -const tokens: AppleRefreshedTokens = await apple.refreshAccessToken(refreshToken); +import { Apple } from "arctic"; + +const apple = new Apple(clientId, teamId, keyId, pkcs8PrivateKey, redirectURI); ``` -## Get user profile +Here is an example to extract the PKCS#8 key from the PEM certificate. -Parse the ID token. See [ID token claims](https://developer.apple.com/documentation/sign_in_with_apple/sign_in_with_apple_rest_api/authenticating_users_with_sign_in_with_apple#3383773). +```ts +import { base64 } from "@oslojs/encoding"; -## Requesting scopes +const certificate = `-----BEGIN PRIVATE KEY----- +TmV2ZXIgZ29ubmEgZ2l2ZSB5b3UgdXANCk5ldmVyIGdvbm5hIGxldCB5b3UgZG93bg0KTmV2ZXIgZ29ubmEgcnVuIGFyb3VuZCBhbmQgZGVzZXJ0IHlvdQ0KTmV2ZXIgZ29ubmEgbWFrZSB5b3UgY3J5DQpOZXZlciBnb25uYSBzYXkgZ29vZGJ5ZQ0KTmV2ZXIgZ29ubmEgdGVsbCBhIGxpZSBhbmQgaHVydCB5b3U +-----END PRIVATE KEY-----`; +const privateKey = base64.decodeIgnorePadding( + certificate + .replace("-----BEGIN PRIVATE KEY-----", "") + .replace("-----END PRIVATE KEY-----", "") + .replaceAll("\r", "") + .replaceAll("\n", "") + .trim() +); +``` -When requesting scopes, the `response_mode` param must be set to `form_post`. Unlike the default `"query"` response mode, **Apple will send an application/x-www-form-urlencoded POST request as the callback,** and the user JSON object will be sent in the request body. This is only available the first time the user signs in. Make sure to set the state cookie with the `SameSite=None` attribute. +## Create authorization URL ```ts import { generateState } from "arctic"; const state = generateState(); -const url = await apple.createAuthorizationURL(state); -url.searchParams.set("response_mode", "query"); - -setCookie("state", state, { - secure: true, // set to false in localhost - path: "/", - httpOnly: true, - maxAge: 60 * 10, // 10 min - sameSite: "none" // IMPORTANT! -}); +const scopes = ["name", "email"]; +const url = apple.createAuthorizationURL(state, scopes); ``` -```ts -app.post("/login/apple/callback", async (request: Request) => { - const formData = await request.formData(); - const userJSON = formData.get("user"); - if (typeof userJSON === "string") { - const user = JSON.parse(userJSON); - const { - name: { firstName, lastName }, - email - } = user; - } -}); +### Requesting scopes + +When requesting scopes, the `response_mode` param must be set to `form_post`. Unlike the default `"query"` response mode, **Apple will send an application/x-www-form-urlencoded POST request as the callback,** and the user JSON object will be sent in the request body. This is only available the first time the user signs in. + +Since this is a cross-origin form request, make sure to relax your CSRF protections, including setting `SameSite` attribute of the state cookie to `None`. + +``` +/callback?user=%7B%22name%22%3A%7B%22firstName%22%3A%22John%22%2C%22lastName%22%3A%22Doe%22%7D%2C%22email%22%3A%22john%40example.com%22%7D&state=STATE ``` -If you have CSRF protection implemented, you must allow form submissions from Apple or disable CSRF protection for specific routes. +```json +{ "name": { "firstName": "John", "lastName": "Doe" }, "email": "john@example.com" } +``` + +## Validate authorization code + +`validateAuthorizationCode()` will either return an [`OAuth2Tokens`](/reference/main/OAuth2Tokens), or throw one of [`OAuth2RequestError`](/reference/main/OAuth2RequestError), [`ArcticFetchError`](/reference/main/ArcticFetchError), or a standard `Error` (parse errors). The ID token will always be returned regardless of the scope. T access token and refresh token currently does not have any uses. + +Arctic provides [`decodeIdToken()`](/reference/main/decodeIdToken) for decoding the ID token's payload. ```ts -import { verifyRequestOrigin } from "lucia"; - -const originHeader = request.headers.get("Origin"); -const hostHeader = request.headers.get("Host"); -if ( - !originHeader || - !hostHeader || - !verifyRequestOrigin(originHeader, [hostHeader, "appleid.apple.com"]) -) { - return new Response(null, { - status: 403 - }); +import { OAuth2RequestError, ArcticFetchError } from "arctic"; + +try { + const tokens = await apple.validateAuthorizationCode(code, codeVerifier); + const idToken = tokens.idToken(); +} catch (e) { + if (e instanceof OAuth2RequestError) { + // Invalid authorization code, credentials, or redirect URI + const code = e.code; + // ... + } + if (e instanceof ArcticFetchError) { + // Failed to call `fetch()` + const cause = e.cause; + // ... + } + // Parse error } ``` diff --git a/docs/pages/providers/atlassian.md b/docs/pages/providers/atlassian.md index 348939e6..34991b6c 100644 --- a/docs/pages/providers/atlassian.md +++ b/docs/pages/providers/atlassian.md @@ -4,7 +4,11 @@ title: "Atlassian" # Atlassian -For usage, see [OAuth 2.0 provider](/guides/oauth2). +OAuth 2.0 provider for Atlassian. + +Also see the [OAuth 2.0](/guides/oauth2) guide. + +## Initialization ```ts import { Atlassian } from "arctic"; @@ -12,13 +16,64 @@ import { Atlassian } from "arctic"; const atlassian = new Atlassian(clientId, clientSecret, redirectURI); ``` +## Create authorization URL + ```ts -const url: URL = await atlassian.createAuthorizationURL(state, { - // optional - scopes -}); -const tokens: AtlassianTokens = await atlassian.validateAuthorizationCode(code); -const tokens: AtlassianTokens = await atlassian.refreshAccessToken(refreshToken); +import { generateState } from "arctic"; + +const state = generateState(); +const scopes = ["write:jira-work", "read:jira-user"]; +const url = atlassian.createAuthorizationURL(state, scopes); +``` + +## Validate authorization code + +`validateAuthorizationCode()` will either return an [`OAuth2Tokens`](/reference/main/OAuth2Tokens), or throw one of [`OAuth2RequestError`](/reference/main/OAuth2RequestError), [`ArcticFetchError`](/reference/main/ArcticFetchError), or a standard `Error` (parse errors). Atlassian returns an access token, the access token expiration, and a refresh token. + +```ts +import { OAuth2RequestError, ArcticFetchError } from "arctic"; + +try { + const tokens = await atlassian.validateAuthorizationCode(code); + const accessToken = tokens.accessToken(); + const accessTokenExpiresAt = tokens.accessTokenExpiresAt(); + const refreshToken = tokens.refreshToken(); +} catch (e) { + if (e instanceof OAuth2RequestError) { + // Invalid authorization code, credentials, or redirect URI + const code = e.code; + // ... + } + if (e instanceof ArcticFetchError) { + // Failed to call `fetch()` + const cause = e.cause; + // ... + } + // Parse error +} +``` + +## Refresh access tokens + +Use `refreshAccessToken()` to get a new access token using a refresh token. This method's behavior is identical to `validateAuthorizationCode()`. + +```ts +import { OAuth2RequestError, ArcticFetchError } from "arctic"; + +try { + const tokens = await atlassian.refreshAccessToken(accessToken); + const accessToken = tokens.accessToken(); + const accessTokenExpiresAt = tokens.accessTokenExpiresAt(); + const refreshToken = tokens.refreshToken(); +} catch (e) { + if (e instanceof OAuth2RequestError) { + // Invalid authorization code, credentials, or redirect URI + } + if (e instanceof ArcticFetchError) { + // Failed to call `fetch()` + } + // Parse error +} ``` ## Get user profile @@ -26,16 +81,14 @@ const tokens: AtlassianTokens = await atlassian.refreshAccessToken(refreshToken) Add the `read:me` scope and use the [`/me` endpoint](https://developer.atlassian.com/cloud/confluence/oauth-2-3lo-apps/#how-do-i-retrieve-the-public-profile-of-the-authenticated-user-). ```ts -const url = await atlassian.createAuthorizationURL(state, { - scopes: ["read:me"] -}); +const scopes = ["read:me"]; +const url = atlassian.createAuthorizationURL(state, scopes); ``` ```ts -const tokens = await atlassian.validateAuthorizationCode(code); const response = await fetch("https://api.atlassian.com/me", { headers: { - Authorization: `Bearer ${tokens.accessToken}` + Authorization: `Bearer ${accessToken}` } }); const user = await response.json(); diff --git a/docs/pages/providers/auth0.md b/docs/pages/providers/auth0.md index de98f303..a6a32ac2 100644 --- a/docs/pages/providers/auth0.md +++ b/docs/pages/providers/auth0.md @@ -4,45 +4,124 @@ title: "Auth0" # Auth0 -Implements OpenID Connect. +OAuth 2.0 provider for Auth0. -For usage, see [OAuth 2.0 provider](/guides/oauth2). +Also see the [OAuth 2.0](/guides/oauth2) guide. + +## Initialization + +The domain should not include the protocol or path. ```ts import { Auth0 } from "arctic"; -const appDomain = "https://xxx.auth0.com"; +const domain = "xxx.auth0.com"; +const auth0 = new Auth0(domain, clientId, clientSecret, redirectURI); +``` -const auth0 = new Auth0(appDomain, clientId, clientSecret, redirectURI); +## Create authorization URL + +```ts +import { generateState } from "arctic"; + +const state = generateState(); +const scopes = ["openid", "profile"]; +const url = auth0.createAuthorizationURL(state, scopes); ``` +## Validate authorization code + +`validateAuthorizationCode()` will either return an [`OAuth2Tokens`](/reference/main/OAuth2Tokens), or throw one of [`OAuth2RequestError`](/reference/main/OAuth2RequestError), [`ArcticFetchError`](/reference/main/ArcticFetchError), or a standard `Error` (parse errors). Auth0 returns an access token, the access token expiration, and a refresh token. + ```ts -const url: URL = await auth0.createAuthorizationURL(state, { - // optional - scopes -}); -const tokens: Auth0Tokens = await auth0.validateAuthorizationCode(code); -const tokens: Auth0Tokens = await auth0.refreshAccessToken(refreshToken); +import { OAuth2RequestError, ArcticFetchError } from "arctic"; + +try { + const tokens = await auth0.validateAuthorizationCode(code); + const accessToken = tokens.accessToken(); + const accessTokenExpiresAt = tokens.accessTokenExpiresAt(); + const refreshToken = tokens.refreshToken(); +} catch (e) { + if (e instanceof OAuth2RequestError) { + // Invalid authorization code, credentials, or redirect URI + const code = e.code; + // ... + } + if (e instanceof ArcticFetchError) { + // Failed to call `fetch()` + const cause = e.cause; + // ... + } + // Parse error +} ``` -## Get user profile +## Refresh access tokens -Add the `profile` scope. Optionally add the `email` scope to get user email. +Use `refreshAccessToken()` to get a new access token using a refresh token. Auth0 returns the same values as during the authorization code validation. This method also returns `OAuth2Tokens` and throws the same errors as `validateAuthorizationCode()` ```ts -const url = await auth0.createAuthorizationURL(state, { - scopes: ["profile", "email"] -}); +import { OAuth2RequestError, ArcticFetchError } from "arctic"; + +try { + const tokens = await auth0.refreshAccessToken(accessToken); + const accessToken = tokens.accessToken(); + const accessTokenExpiresAt = tokens.accessTokenExpiresAt(); + const refreshToken = tokens.refreshToken(); +} catch (e) { + if (e instanceof OAuth2RequestError) { + // Invalid authorization code, credentials, or redirect URI + } + if (e instanceof ArcticFetchError) { + // Failed to call `fetch()` + } + // Parse error +} +``` + +## OpenID Connect + +Use OpenID Connect with the `openid` scope to get the user's profile with an ID token or the `userinfo` endpoint. Arctic provides [`decodeIdToken()`](/reference/main/decodeIdToken) for decoding the token's payload. + +```ts +const scopes = ["openid"]; +const url = auth0.createAuthorizationURL(state, codeVerifier, scopes); ``` -Parse the ID token or use the `userinfo` endpoint. See [ID token structure](https://auth0.com/docs/secure/tokens/id-tokens/id-token-structure#sample-id-token). +```ts +import { decodeIdToken } from "arctic"; + +const tokens = await auth0.validateAuthorizationCode(code, codeVerifier); +const idToken = tokens.idToken(); +const claims = decodeIdToken(idToken); +``` ```ts -const tokens = await auth0.validateAuthorizationCode(code); const response = await fetch("https://xxx.auth.com/userinfo", { headers: { - Authorization: `Bearer ${tokens.accessToken}` + Authorization: `Bearer ${accessToken}` } }); const user = await response.json(); ``` + +### Get user profile + +Make sure to add the `profile` scope to get the user profile and the `email` scope to get the user email. + +```ts +const scopes = ["openid", "profile", "email"]; +const url = auth0.createAuthorizationURL(state, codeVerifier, scopes); +``` + +## Revoke tokens + +Revoke tokens with `revokeToken()`. Currently, only refresh tokens can be revoked. It throws the same errors as `validateAuthorizationCode()`. + +```ts +try { + await box.revokeToken(refreshToken); +} catch (e) { + // Handle errors +} +``` diff --git a/docs/pages/providers/authentik.md b/docs/pages/providers/authentik.md index ecc538bf..b4783db2 100644 --- a/docs/pages/providers/authentik.md +++ b/docs/pages/providers/authentik.md @@ -2,47 +2,112 @@ title: "Authentik" --- -# Authentik +# Okta -For usage, see [OAuth 2.0 provider with PKCE](/guides/oauth2-pkce). +OAuth 2.0 provider for Authentik. + +Also see the [OAuth 2.0 with PKCE](/guides/oauth2-pkce) guide. + +## Initialization + +The `domain` parameter should not include the protocol or path. ```ts import { Authentik } from "arctic"; -const realmURL = "http://example.com"; -const authentik = new Authentik(realmURL, clientId, clientSecret, redirectURI); +const domain = "auth.example.com"; +const authentik = new Authentik(domain, clientId, clientSecret, redirectURI); ``` +## Create authorization URL + ```ts -const url: URL = await authentik.createAuthorizationURL(state, codeVerifier, { - // optional - scopes // "openid" always included -}); -const tokens: AuthentikTokens = await authentik.validateAuthorizationCode(code, codeVerifier); -const tokens: AuthentikTokens = await authentik.refreshAccessToken(refreshToken); -``` +import { generateState, generateCodeVerifier } from "arctic"; -## Get refresh token +const state = generateState(); +const codeVerifier = generateCodeVerifier(); +const scopes = ["openid", "profile"]; +const url = authentik.createAuthorizationURL(state, codeVerifier, scopes); +``` -Authentik with version 2024.2 and higher only provides the access token by default. To get the refresh token as well, you'll need to include the `offline_access` scope. The scope also needs to be enabled in your app's advanced settings (Application > Providers > Edit > Advanced protocol settings > Scopes). +## Validate authorization code +`validateAuthorizationCode()` will either return an [`OAuth2Tokens`](/reference/main/OAuth2Tokens), or throw one of [`OAuth2RequestError`](/reference/main/OAuth2RequestError), [`ArcticFetchError`](/reference/main/ArcticFetchError), or a standard `Error` (parse errors). Actual values returned by Authentik depends on your configuration and version. ```ts -const url: URL = await authentik.createAuthorizationURL(state, codeVerifier, { - scopes: ["profile", "email", "offline_access"] -}); +import { OAuth2RequestError, ArcticFetchError } from "arctic"; + +try { + const tokens = await authentik.validateAuthorizationCode(code, codeVerifier); + const accessToken = tokens.accessToken(); + const accessTokenExpiresAt = tokens.accessTokenExpiresAt(); + const refreshToken = tokens.refreshToken(); +} catch (e) { + if (e instanceof OAuth2RequestError) { + // Invalid authorization code, credentials, or redirect URI + const code = e.code; + // ... + } + if (e instanceof ArcticFetchError) { + // Failed to call `fetch()` + const cause = e.cause; + // ... + } + // Parse error +} ``` -## Get user profile +## OpenID Connect -Authentik provides endpoint `/application/o/userinfo/` that you can use to fetch the user info once you obtain the Bearer token. +Use OpenID Connect with the `openid` scope to get the user's profile with an ID token or the `userinfo` endpoint. Arctic provides [`decodeIdToken()`](/reference/main/decodeIdToken) for decoding the token's payload. ```ts +const scopes = ["openid"]; +const url = keycloak.createAuthorizationURL(state, codeVerifier, scopes); +``` + +```ts +import { decodeIdToken } from "arctic"; + const tokens = await authentik.validateAuthorizationCode(code, codeVerifier); -const response = await fetch("https://example.com/application/o/userinfo/", { - headers: { - Authorization: `Bearer ${tokens.accessToken}` +const idToken = tokens.idToken(); +const claims = decodeIdToken(idToken); +``` + +## Refresh access tokens + +Use `refreshAccessToken()` to get a new access token using a refresh token. This method also returns `OAuth2Tokens` and throws the same errors as `validateAuthorizationCode()`. + +```ts +import { OAuth2RequestError, ArcticFetchError } from "arctic"; + +try { + const tokens = await authentik.refreshAccessToken(accessToken); +} catch (e) { + if (e instanceof OAuth2RequestError) { + // Invalid authorization code, credentials, or redirect URI + } + if (e instanceof ArcticFetchError) { + // Failed to call `fetch()` + } + // Parse error +} +``` + +## Revoke tokens + +Use `revokeToken()` to revoke a token. This can throw the same errors as `validateAuthorizationCode()`. + +```ts +try { + await authentik.revokeToken(token); +} catch (e) { + if (e instanceof OAuth2RequestError) { + // Invalid authorization code, credentials, or redirect URI + } + if (e instanceof ArcticFetchError) { + // Failed to call `fetch()` } -}); -const user = await response.json(); + // Parse error +} ``` diff --git a/docs/pages/providers/bitbucket.md b/docs/pages/providers/bitbucket.md index 6e8c112c..858aee98 100644 --- a/docs/pages/providers/bitbucket.md +++ b/docs/pages/providers/bitbucket.md @@ -4,38 +4,85 @@ title: "Bitbucket" # Bitbucket -For usage, see [OAuth 2.0 provider](/guides/oauth2). +OAuth 2.0 provider for Bitbucket. + +Also see the [OAuth 2.0](/guides/oauth2) guide. + +## Initialization ```ts import { Bitbucket } from "arctic"; -const bitbucket = new Bitbucket(clientId, clientSecret, redirectURI); +const bitBucket = new Bitbucket(clientId, clientSecret, redirectURI); ``` +## Create authorization URL + ```ts -const url: URL = await bitbucket.createAuthorizationURL(state, { - // optional - scopes -}); -const tokens: BitbucketTokens = await bitbucket.validateAuthorizationCode(code); -const tokens: BitbucketTokens = await bitbucket.refreshAccessToken(refreshToken); +import { generateState } from "arctic"; + +const state = generateState(); +const url = bitBucket.createAuthorizationURL(state); ``` -## Get user profile +## Validate authorization code -Add the `account` scope and use the [`/user` endpoint](https://developer.atlassian.com/cloud/bitbucket/rest/api-group-users/#api-user-get). +`validateAuthorizationCode()` will either return an [`OAuth2Tokens`](/reference/main/OAuth2Tokens), or throw one of [`OAuth2RequestError`](/reference/main/OAuth2RequestError), [`ArcticFetchError`](/reference/main/ArcticFetchError), or a standard `Error` (parse errors). BitBucket returns an access token and a refresh token. ```ts -const url = await bitbucket.createAuthorizationURL(state, { - scopes: ["account"] -}); +import { OAuth2RequestError, ArcticFetchError } from "arctic"; + +try { + const tokens = await bitBucket.validateAuthorizationCode(code); + // Accessing other fields will throw an error + const accessToken = tokens.accessToken(); + const refreshToken = tokens.refreshToken(); +} catch (e) { + if (e instanceof OAuth2RequestError) { + // Invalid authorization code, credentials, or redirect URI + const code = e.code; + // ... + } + if (e instanceof ArcticFetchError) { + // Failed to call `fetch()` + const cause = e.cause; + // ... + } + // Parse error +} ``` +## Refresh access tokens + +Use `refreshAccessToken()` to get a new access token using a refresh token. This method's behavior is identical to `validateAuthorizationCode()`. + +```ts +import { OAuth2RequestError, ArcticFetchError } from "arctic"; + +try { + const tokens = await bitBucket.refreshAccessToken(accessToken); + // Accessing other fields will throw an error + const accessToken = tokens.accessToken(); + const refreshToken = tokens.refreshToken(); +} catch (e) { + if (e instanceof OAuth2RequestError) { + // Invalid authorization code, credentials, or redirect URI + } + if (e instanceof ArcticFetchError) { + // Failed to call `fetch()` + } + // Parse error +} +``` + +## Get user profile + +Enable the `account` scope on your account page and use the [`/user` endpoint](https://developer.atlassian.com/cloud/bitbucket/rest/api-group-users/#api-user-get). + ```ts -const tokens = await bitbucket.validateAuthorizationCode(code); const response = await fetch("https://api.bitbucket.org/2.0/user", { headers: { - Authorization: `Bearer ${tokens.accessToken}` + Authorization: `Bearer ${accessToken}` } }); const user = await response.json(); diff --git a/docs/pages/providers/box.md b/docs/pages/providers/box.md index 1d83a577..8052f920 100644 --- a/docs/pages/providers/box.md +++ b/docs/pages/providers/box.md @@ -4,7 +4,11 @@ title: "Box" # Box -For usage, see [OAuth 2.0 provider](/guides/oauth2). +OAuth 2.0 provider for Box. + +Also see the [OAuth 2.0](/guides/oauth2) guide. + +## Initialization ```ts import { Box } from "arctic"; @@ -12,12 +16,39 @@ import { Box } from "arctic"; const box = new Box(clientId, clientSecret, redirectURI); ``` +## Create authorization URL + ```ts -const url: URL = await box.createAuthorizationURL(state, { - // optional - scopes -}); -const tokens: BoxTokens = await box.validateAuthorizationCode(code); +import { generateState } from "arctic"; + +const state = generateState(); +const scopes = ["root_readonly", "manage_managed_users"]; +const url = box.createAuthorizationURL(state, scopes); +``` + +## Validate authorization code + +`validateAuthorizationCode()` will either return an [`OAuth2Tokens`](/reference/main/OAuth2Tokens), or throw one of [`OAuth2RequestError`](/reference/main/OAuth2RequestError), [`ArcticFetchError`](/reference/main/ArcticFetchError), or a standard `Error` (parse errors). Box will only return an access token (no expiration). + +```ts +import { OAuth2RequestError, ArcticFetchError } from "arctic"; + +try { + const tokens = await box.validateAuthorizationCode(code); + const accessToken = tokens.accessToken(); +} catch (e) { + if (e instanceof OAuth2RequestError) { + // Invalid authorization code, credentials, or redirect URI + const code = e.code; + // ... + } + if (e instanceof ArcticFetchError) { + // Failed to call `fetch()` + const cause = e.cause; + // ... + } + // Parse error +} ``` ## Get user profile @@ -25,11 +56,22 @@ const tokens: BoxTokens = await box.validateAuthorizationCode(code); Use the [`/users/me` endpoint](https://developer.box.com/reference/get-users-me). ```ts -const tokens = await box.validateAuthorizationCode(code); const response = await fetch("https://api.box.com/2.0/users/me", { headers: { - Authorization: `Bearer ${tokens.accessToken}` + Authorization: `Bearer ${accessToken}` } }); const user = await response.json(); ``` + +## Revoke tokens + +Revoke tokens with `revokeToken()`. Revoking a refresh token will also invalidate access tokens issued with it. It throws the same errors as `validateAuthorizationCode()`. + +```ts +try { + await box.revokeToken(token); +} catch (e) { + // Handle errors +} +``` diff --git a/docs/pages/providers/coinbase.md b/docs/pages/providers/coinbase.md index d6f4b6dc..7baebf2a 100644 --- a/docs/pages/providers/coinbase.md +++ b/docs/pages/providers/coinbase.md @@ -4,24 +4,76 @@ title: "Coinbase" # Coinbase -For usage, see [OAuth 2.0 provider](/guides/oauth2). +OAuth 2.0 provider for Coinbase. + +Also see the [OAuth 2.0](/guides/oauth2) guide. + +## Initialization ```ts import { Coinbase } from "arctic"; -const coinbase = new Coinbase(clientId, clientSecret, { - // optional - redirectURI -}); +const coinbase = new Coinbase(clientId, clientSecret, redirectURI); ``` +## Create authorization URL + ```ts -const url: URL = await coinbase.createAuthorizationURL(state, { - // optional - scopes -}); -const tokens: CoinbaseTokens = await coinbase.validateAuthorizationCode(code); -const tokens: CoinbaseTokens = await coinbase.refreshAccessToken(refreshToken); +import { generateState } from "arctic"; + +const state = generateState(); +const scopes = ["wallet:user:email", "wallet:accounts:read"]; +const url = coinbase.createAuthorizationURL(state, scopes); +``` + +## Validate authorization code + +`validateAuthorizationCode()` will either return an [`OAuth2Tokens`](/reference/main/OAuth2Tokens), or throw one of [`OAuth2RequestError`](/reference/main/OAuth2RequestError), [`ArcticFetchError`](/reference/main/ArcticFetchError), or a standard `Error` (parse errors). Coinbase returns an access token, the access token expiration, and a refresh token. + +```ts +import { OAuth2RequestError, ArcticFetchError } from "arctic"; + +try { + const tokens = await coinbase.validateAuthorizationCode(code); + const accessToken = tokens.accessToken(); + const accessTokenExpiresAt = tokens.accessTokenExpiresAt(); + const refreshToken = tokens.refreshToken(); +} catch (e) { + if (e instanceof OAuth2RequestError) { + // Invalid authorization code, credentials, or redirect URI + const code = e.code; + // ... + } + if (e instanceof ArcticFetchError) { + // Failed to call `fetch()` + const cause = e.cause; + // ... + } + // Parse error +} +``` + +## Refresh access tokens + +Use `refreshAccessToken()` to get a new access token using a refresh token. This method's behavior is identical to `validateAuthorizationCode()`. + +```ts +import { OAuth2RequestError, ArcticFetchError } from "arctic"; + +try { + const tokens = await coinbase.refreshAccessToken(accessToken); + const accessToken = tokens.accessToken(); + const accessTokenExpiresAt = tokens.accessTokenExpiresAt(); + const refreshToken = tokens.refreshToken(); +} catch (e) { + if (e instanceof OAuth2RequestError) { + // Invalid authorization code, credentials, or redirect URI + } + if (e instanceof ArcticFetchError) { + // Failed to call `fetch()` + } + // Parse error +} ``` ## Get user profile @@ -29,11 +81,28 @@ const tokens: CoinbaseTokens = await coinbase.refreshAccessToken(refreshToken); Use the [`/user` endpoint](https://docs.cloud.coinbase.com/sign-in-with-coinbase/docs/api-users#show-current-user). ```ts -const tokens = await coinbase.validateAuthorizationCode(code); const response = await fetch("https://api.coinbase.com/v2/user", { headers: { - Authorization: `Bearer ${tokens.accessToken}` + Authorization: `Bearer ${accessToken}` } }); const user = await response.json(); ``` + +## Revoke tokens + +Revoke tokens with `revokeToken()`. This can throw the same errors as `validateAuthorizationCode()`. + +```ts +try { + await coinbase.revokeToken(token); +} catch (e) { + if (e instanceof OAuth2RequestError) { + // Invalid authorization code, credentials, or redirect URI + } + if (e instanceof ArcticFetchError) { + // Failed to call `fetch()` + } + // Parse error +} +``` diff --git a/docs/pages/providers/discord.md b/docs/pages/providers/discord.md index 9585b66a..544ab428 100644 --- a/docs/pages/providers/discord.md +++ b/docs/pages/providers/discord.md @@ -4,7 +4,11 @@ title: "Discord" # Discord -For usage, see [OAuth 2.0 provider](/guides/oauth2). +OAuth 2.0 provider for Discord. + +Also see the [OAuth 2.0](/guides/oauth2) guide. + +## Initialization ```ts import { Discord } from "arctic"; @@ -12,13 +16,64 @@ import { Discord } from "arctic"; const discord = new Discord(clientId, clientSecret, redirectURI); ``` +## Create authorization URL + ```ts -const url: URL = await discord.createAuthorizationURL(state, { - // optional - scopes -}); -const tokens: DiscordTokens = await discord.validateAuthorizationCode(code); -const tokens: DiscordTokens = await discord.refreshAccessToken(refreshToken); +import { generateState } from "arctic"; + +const state = generateState(); +const scopes = ["email", "activities.read"]; +const url = discord.createAuthorizationURL(state, scopes); +``` + +## Validate authorization code + +`validateAuthorizationCode()` will either return an [`OAuth2Tokens`](/reference/main/OAuth2Tokens), or throw one of [`OAuth2RequestError`](/reference/main/OAuth2RequestError), [`ArcticFetchError`](/reference/main/ArcticFetchError), or a standard `Error` (parse errors). Discord returns an access token, the access token expiration, and a refresh token. + +```ts +import { OAuth2RequestError, ArcticFetchError } from "arctic"; + +try { + const tokens = await discord.validateAuthorizationCode(code); + const accessToken = tokens.accessToken(); + const accessTokenExpiresAt = tokens.accessTokenExpiresAt(); + const refreshToken = tokens.refreshToken(); +} catch (e) { + if (e instanceof OAuth2RequestError) { + // Invalid authorization code, credentials, or redirect URI + const code = e.code; + // ... + } + if (e instanceof ArcticFetchError) { + // Failed to call `fetch()` + const cause = e.cause; + // ... + } + // Parse error +} +``` + +## Refresh access tokens + +Use `refreshAccessToken()` to get a new access token using a refresh token. Discord returns the same values as during the authorization code validation. This method also returns `OAuth2Tokens` and throws the same errors as `validateAuthorizationCode()` + +```ts +import { OAuth2RequestError, ArcticFetchError } from "arctic"; + +try { + const tokens = await discord.refreshAccessToken(accessToken); + const accessToken = tokens.accessToken(); + const accessTokenExpiresAt = tokens.accessTokenExpiresAt(); + const refreshToken = tokens.refreshToken(); +} catch (e) { + if (e instanceof OAuth2RequestError) { + // Invalid authorization code, credentials, or redirect URI + } + if (e instanceof ArcticFetchError) { + // Failed to call `fetch()` + } + // Parse error +} ``` ## Get user profile @@ -26,17 +81,33 @@ const tokens: DiscordTokens = await discord.refreshAccessToken(refreshToken); Add the `identify` scope and use the [`/users/@me` endpoint](https://discord.com/developers/docs/resources/user#get-current-user). ```ts -const url = await discord.createAuthorizationURL(state, { - scopes: ["identify"] -}); +const scopes = ["identify"]; +const url = discord.createAuthorizationURL(state, scopes); ``` ```ts -const tokens = await discord.validateAuthorizationCode(code); const response = await fetch("https://discord.com/api/users/@me", { headers: { - Authorization: `Bearer ${tokens.accessToken}` + Authorization: `Bearer ${accessToken}` } }); const user = await response.json(); ``` + +## Revoke tokens + +Pass a token to `revokeToken()` to revoke all tokens associated with the authorization. This can throw the same errors as `validateAuthorizationCode()`. + +```ts +try { + await discord.revokeToken(token); +} catch (e) { + if (e instanceof OAuth2RequestError) { + // Invalid authorization code, credentials, or redirect URI + } + if (e instanceof ArcticFetchError) { + // Failed to call `fetch()` + } + // Parse error +} +``` diff --git a/docs/pages/providers/dribbble.md b/docs/pages/providers/dribbble.md index d7d74b46..6e377a7e 100644 --- a/docs/pages/providers/dribbble.md +++ b/docs/pages/providers/dribbble.md @@ -4,20 +4,51 @@ title: "Dribbble" # Dribbble -For usage, see [OAuth 2.0 provider](/guides/oauth2). +OAuth 2.0 provider for Dribbble. + +Also see the [OAuth 2.0](/guides/oauth2) guide. + +## Initialization ```ts -import { Dribbble } from "arctic"; +import { Dribble } from "arctic"; -const dribbble = new Dribbble(clientId, clientSecret, redirectURI); +const dribble = new Dribble(clientId, clientSecret, redirectURI); ``` +## Create authorization URL + ```ts -const url: URL = await dribbble.createAuthorizationURL(state, { - // optional - scopes -}); -const tokens: DribbbleTokens = await dribbble.validateAuthorizationCode(code); +import { generateState } from "arctic"; + +const state = generateState(); +const scopes = ["public", "upload"]; +const url = dribble.createAuthorizationURL(state, scopes); +``` + +## Validate authorization code + +`validateAuthorizationCode()` will either return an [`OAuth2Tokens`](/reference/main/OAuth2Tokens), or throw one of [`OAuth2RequestError`](/reference/main/OAuth2RequestError), [`ArcticFetchError`](/reference/main/ArcticFetchError), or a standard `Error` (parse errors). Dribble will only return an access token (no expiration). + +```ts +import { OAuth2RequestError, ArcticFetchError } from "arctic"; + +try { + const tokens = await dribble.validateAuthorizationCode(code); + const accessToken = tokens.accessToken(); +} catch (e) { + if (e instanceof OAuth2RequestError) { + // Invalid authorization code, credentials, or redirect URI + const code = e.code; + // ... + } + if (e instanceof ArcticFetchError) { + // Failed to call `fetch()` + const cause = e.cause; + // ... + } + // Parse error +} ``` ## Get user profile @@ -25,10 +56,9 @@ const tokens: DribbbleTokens = await dribbble.validateAuthorizationCode(code); Use the [`/user` endpoint](https://developer.dribbble.com/v2/user). ```ts -const tokens = await dribbble.validateAuthorizationCode(code); const response = await fetch("https://api.dribbble.com/v2/user", { headers: { - Authorization: `Bearer ${tokens.accessToken}` + Authorization: `Bearer ${accessToken}` } }); const user = await response.json(); diff --git a/docs/pages/providers/dropbox.md b/docs/pages/providers/dropbox.md index a8f5547c..98d9d525 100644 --- a/docs/pages/providers/dropbox.md +++ b/docs/pages/providers/dropbox.md @@ -4,9 +4,11 @@ title: "Dropbox" # Dropbox -Implements OpenID Connect. +OAuth 2.0 provider for Dropbox. -For usage, see [OAuth 2.0 provider](/guides/oauth2). +Also see the [OAuth 2.0](/guides/oauth2) guide. + +## Initialization ```ts import { Dropbox } from "arctic"; @@ -14,44 +16,131 @@ import { Dropbox } from "arctic"; const dropbox = new Dropbox(clientId, clientSecret, redirectURI); ``` +## Create authorization URL + ```ts -const url: URL = await dropbox.createAuthorizationURL(state, { - // optional - scopes // "openid" is always included -}); -const tokens: DropboxTokens = await dropbox.validateAuthorizationCode(code); -const tokens: DropboxRefreshedTokens = await dropbox.refreshAccessToken(refreshToken); +import { generateState } from "arctic"; + +const state = generateState(); +const scopes = ["account_info.read", "files.content.read"]; +const url = dropbox.createAuthorizationURL(state, scopes); +``` + +## Validate authorization code + +`validateAuthorizationCode()` will either return an [`OAuth2Tokens`](/reference/main/OAuth2Tokens), or throw one of [`OAuth2RequestError`](/reference/main/OAuth2RequestError), [`ArcticFetchError`](/reference/main/ArcticFetchError), or a standard `Error` (parse errors). Dropbox returns an access token and its expiration. + +```ts +import { OAuth2RequestError, ArcticFetchError } from "arctic"; + +try { + const tokens = await dropbox.validateAuthorizationCode(code); + const accessToken = tokens.accessToken(); + const accessTokenExpiresAt = tokens.accessTokenExpiresAt(); +} catch (e) { + if (e instanceof OAuth2RequestError) { + // Invalid authorization code, credentials, or redirect URI + const code = e.code; + // ... + } + if (e instanceof ArcticFetchError) { + // Failed to call `fetch()` + const cause = e.cause; + // ... + } + // Parse error +} ``` -## Get user profile +## OpenID Connect -Add the `profile` scope. Optionally add the `email` scope to get user email. +Use OpenID Connect with the `openid` scope to get the user's profile with an ID token or the `userinfo` endpoint. Arctic provides [`decodeIdToken()`](/reference/main/decodeIdToken) for decoding the token's payload. -Parse the ID token or use the [`userinfo` endpoint](https://api.dropboxapi.com/2/openid/userinfo). See [supported claims](https://developers.dropbox.com/oidc-guide#oidc-standard). +Also see [supported claims](https://developers.dropbox.com/oidc-guide#oidc-standard). + +```ts +const scopes = ["openid"]; +const url = dropbox.createAuthorizationURL(state, codeVerifier, scopes); +``` + +```ts +import { decodeIdToken } from "arctic"; + +const tokens = await dropbox.validateAuthorizationCode(code, codeVerifier); +const idToken = tokens.idToken(); +const claims = decodeIdToken(idToken); +``` ```ts -const tokens = await dropbox.validateAuthorizationCode(code); const response = await fetch("https://api.dropboxapi.com/2/openid/userinfo", { - method: "POST". headers: { - Authorization: `Bearer ${tokens.accessToken}` + Authorization: `Bearer ${accessToken}` } }); const user = await response.json(); ``` +### Get user profile + +Make sure to add the `profile` scope to get the user profile and the `email` scope to get the user email. + +```ts +const scopes = ["openid", "profile", "email"]; +const url = google.createAuthorizationURL(state, codeVerifier, scopes); +``` + The [`/users/get_current_account` endpoint](https://www.dropbox.com/developers/documentation/http/documentation#users-get_current_account) can also be used. -## Get refresh token +## Refresh access tokens -Set `access_type` params to `offline`. +Set the `access_type` parameter to `offline` to get refresh tokens. ```ts -const url = await dropbox.createAuthorizationURL(); +const url = dropbox.createAuthorizationURL(state, codeVerifier, scopes); url.searchParams.set("access_type", "offline"); ``` ```ts const tokens = await dropbox.validateAuthorizationCode(code); -const refreshToken: string | null = tokens.refreshToken; +const accessToken = tokens.accessToken(); +const accessTokenExpiresAt = tokens.accessTokenExpiresAt(); +const refreshToken = tokens.refreshToken(); +``` + +Use `refreshAccessToken()` to get a new access token using a refresh token. Dropbox will only return the access token and its expiration. This method also returns `OAuth2Tokens` and throws the same errors as `validateAuthorizationCode()` + +```ts +import { OAuth2RequestError, ArcticFetchError } from "arctic"; + +try { + const tokens = await dropbox.refreshAccessToken(accessToken); + const accessToken = tokens.accessToken(); + const accessTokenExpiresAt = tokens.accessTokenExpiresAt(); +} catch (e) { + if (e instanceof OAuth2RequestError) { + // Invalid authorization code, credentials, or redirect URI + } + if (e instanceof ArcticFetchError) { + // Failed to call `fetch()` + } + // Parse error +} +``` + +## Revoke tokens + +Pass a token to `revokeToken()` to revoke all tokens associated with the authorization (in other words, both tokens will be revoked regardless of which one you passed). This can throw the same errors as `validateAuthorizationCode()`. + +```ts +try { + await dropbox.revokeToken(token); +} catch (e) { + if (e instanceof OAuth2RequestError) { + // Invalid authorization code, credentials, or redirect URI + } + if (e instanceof ArcticFetchError) { + // Failed to call `fetch()` + } + // Parse error +} ``` diff --git a/docs/pages/providers/facebook.md b/docs/pages/providers/facebook.md index 4004ec35..8d4f92f8 100644 --- a/docs/pages/providers/facebook.md +++ b/docs/pages/providers/facebook.md @@ -4,7 +4,11 @@ title: "Facebook" # Facebook -For usage, see [OAuth 2.0 provider](/guides/oauth2). +OAuth 2.0 provider for Facebook. + +Also see the [OAuth 2.0](/guides/oauth2) guide. + +## Initialization ```ts import { Facebook } from "arctic"; @@ -12,12 +16,40 @@ import { Facebook } from "arctic"; const facebook = new Facebook(clientId, clientSecret, redirectURI); ``` +## Create authorization URL + ```ts -const url: URL = await facebook.createAuthorizationURL(state, { - // optional - scopes -}); -const tokens: FacebookTokens = await facebook.validateAuthorizationCode(code); +import { generateState } from "arctic"; + +const state = generateState(); +const scopes = ["email", "public_profile"]; +const url = facebook.createAuthorizationURL(state, scopes); +``` + +## Validate authorization code + +`validateAuthorizationCode()` will either return an [`OAuth2Tokens`](/reference/main/OAuth2Tokens), or throw one of [`OAuth2RequestError`](/reference/main/OAuth2RequestError), [`ArcticFetchError`](/reference/main/ArcticFetchError), or a standard `Error` (parse errors). Facebook will return an access token with an expiration. + +```ts +import { OAuth2RequestError, ArcticFetchError } from "arctic"; + +try { + const tokens = await facebook.validateAuthorizationCode(code); + const accessToken = tokens.accessToken(); + const accessTokenExpiresAt = tokens.accessTokenExpiresAt(); +} catch (e) { + if (e instanceof OAuth2RequestError) { + // Invalid authorization code, credentials, or redirect URI + const code = e.code; + // ... + } + if (e instanceof ArcticFetchError) { + // Failed to call `fetch()` + const cause = e.cause; + // ... + } + // Parse error +} ``` ## Get user profile @@ -25,10 +57,8 @@ const tokens: FacebookTokens = await facebook.validateAuthorizationCode(code); Use the `/me` endpoint. See [user fields](https://developers.facebook.com/docs/graph-api/reference/user#Reading). ```ts -const tokens = await facebook.validateAuthorizationCode(code); - const url = new Request("https://graph.facebook.com/me"); -url.searchParams.set("access_token", tokens.accessToken); +url.searchParams.set("access_token", accessToken); url.searchParams.set("fields", ["id", "name", "picture", "email"].join(",")); const response = await fetch(url); const user = await response.json(); diff --git a/docs/pages/providers/figma.md b/docs/pages/providers/figma.md index 2fd14d78..744dc8e8 100644 --- a/docs/pages/providers/figma.md +++ b/docs/pages/providers/figma.md @@ -4,7 +4,11 @@ title: "Figma" # Figma -For usage, see [OAuth 2.0 provider](/guides/oauth2). +OAuth 2.0 provider for Figma. + +Also see the [OAuth 2.0](/guides/oauth2) guide. + +## Initialization ```ts import { Figma } from "arctic"; @@ -12,13 +16,63 @@ import { Figma } from "arctic"; const figma = new Figma(clientId, clientSecret, redirectURI); ``` +## Create authorization URL + ```ts -const url: URL = await figma.createAuthorizationURL(state, { - // optional - scopes -}); -const tokens: FigmaTokens = await figma.validateAuthorizationCode(code); -const tokens: FigmaRefreshedTokens = await figma.refreshAccessToken(refreshToken); +import { generateState } from "arctic"; + +const state = generateState(); +const scopes = ["files:read", "file_variables:read"]; +const url = figma.createAuthorizationURL(state, scopes); +``` + +## Validate authorization code + +`validateAuthorizationCode()` will either return an [`OAuth2Tokens`](/reference/main/OAuth2Tokens), or throw one of [`OAuth2RequestError`](/reference/main/OAuth2RequestError), [`ArcticFetchError`](/reference/main/ArcticFetchError), or a standard `Error` (parse errors). Figma returns an access token, the access token expiration, and a refresh token. + +```ts +import { OAuth2RequestError, ArcticFetchError } from "arctic"; + +try { + const tokens = await figma.validateAuthorizationCode(code); + const accessToken = tokens.accessToken(); + const accessTokenExpiresAt = tokens.accessTokenExpiresAt(); + const refreshToken = tokens.refreshToken(); +} catch (e) { + if (e instanceof OAuth2RequestError) { + // Invalid authorization code, credentials, or redirect URI + const code = e.code; + // ... + } + if (e instanceof ArcticFetchError) { + // Failed to call `fetch()` + const cause = e.cause; + // ... + } + // Parse error +} +``` + +## Refresh access tokens + +Use `refreshAccessToken()` to get a new access token using a refresh token. Figma only returns an access token and its expiration. This method also returns `OAuth2Tokens` and throws the same errors as `validateAuthorizationCode()` + +```ts +import { OAuth2RequestError, ArcticFetchError } from "arctic"; + +try { + const tokens = await figma.refreshAccessToken(accessToken); + const accessToken = tokens.accessToken(); + const accessTokenExpiresAt = tokens.accessTokenExpiresAt(); +} catch (e) { + if (e instanceof OAuth2RequestError) { + // Invalid authorization code, credentials, or redirect URI + } + if (e instanceof ArcticFetchError) { + // Failed to call `fetch()` + } + // Parse error +} ``` ## Get user profile @@ -26,10 +80,9 @@ const tokens: FigmaRefreshedTokens = await figma.refreshAccessToken(refreshToken Use the [`/me` endpoint](https://www.figma.com/developers/api#get-me-endpoint). ```ts -const figma = await discord.validateAuthorizationCode(code); const response = await fetch("https://api.figma.com/v1/me", { headers: { - Authorization: `Bearer ${tokens.accessToken}` + Authorization: `Bearer ${accessToken}` } }); const user = await response.json(); diff --git a/docs/pages/providers/github.md b/docs/pages/providers/github.md index e7e6f5ce..0d69c1d9 100644 --- a/docs/pages/providers/github.md +++ b/docs/pages/providers/github.md @@ -4,24 +4,88 @@ title: "GitHub" # GitHub -For usage, see [OAuth 2.0 provider](/guides/oauth2). +OAuth 2.0 provider for GitHub Apps and OAuth Apps. + +Also see the [OAuth 2.0](/guides/oauth2) guide. + +## Initialization + +The redirect URI is optional but required by GitHub if there are multiple URIs defined. ```ts import { GitHub } from "arctic"; -const github = new GitHub(clientId, clientSecret, { - // optional - redirectURI, // required when multiple redirect URIs are defined - enterpriseDomain: "https://example.com" // the base URL of your GitHub Enterprise Server instance -}); +const github = new GitHub(clientId, clientSecret, null); +const github = new GitHub(clientId, clientSecret, redirectURI); ``` +## Create authorization URL + ```ts -const url: URL = await github.createAuthorizationURL(state, { - // optional - scopes -}); -const tokens: GitHubTokens = await github.validateAuthorizationCode(code); +import { generateState } from "arctic"; + +const state = generateState(); +const scopes = ["user:email", "repo"]; +const url = github.createAuthorizationURL(state, scopes); +``` + +## Validate authorization code + +`validateAuthorizationCode()` will either return an [`OAuth2Tokens`](/reference/main/OAuth2Tokens), or throw one of [`OAuth2RequestError`](/reference/main/OAuth2RequestError), [`ArcticFetchError`](/reference/main/ArcticFetchError), or a standard `Error` (parse errors). OAuth Apps will only return an access token (no expiration). + +```ts +import { OAuth2RequestError, ArcticFetchError } from "arctic"; + +try { + const tokens = await github.validateAuthorizationCode(code); + const accessToken = tokens.accessToken(); +} catch (e) { + if (e instanceof OAuth2RequestError) { + // Invalid authorization code, credentials, or redirect URI + const code = e.code; + // ... + } + if (e instanceof ArcticFetchError) { + // Failed to call `fetch()` + const cause = e.cause; + // ... + } + // Parse error +} +``` + +If you're using GitHub Apps, GitHub will provide an expiration for the access token alongside a refresh token. + +```ts +const tokens = await github.validateAuthorizationCode(code); +const accessToken = tokens.accessToken(); +const accessTokenExpiresAt = tokens.accessTokenExpiresAt(); +const refreshToken = tokens.refreshToken(); +const refreshTokenExpiresAt = tokens.refreshTokenExpiresAt(); +``` + +## Refresh access tokens + +For GitHub Apps, use `refreshAccessToken()` to get a new access token using a refresh token. The behavior is identical to `validateAuthorizationCode()`. + +```ts +import { OAuth2RequestError, ArcticFetchError } from "arctic"; + +try { + const tokens = await github.refreshAccessToken(accessToken); + const accessToken = tokens.accessToken(); + const accessTokenExpiresAt = tokens.accessTokenExpiresAt(); + const refreshToken = tokens.refreshToken(); + const refreshTokenExpiresAt = tokens.refreshTokenExpiresAt(); +} catch (e) { + if (e instanceof OAuth2RequestError) { + // Invalid authorization code, credentials, or redirect URI + } + if (e instanceof ArcticFetchError) { + // Failed to call `fetch()` + } + // Parse error +} ``` ## Get user profile @@ -31,27 +95,26 @@ Use the [`/user` endpoint](https://docs.github.com/en/rest/users/users?apiVersio ```ts const response = await fetch("https://api.github.com/user", { headers: { - Authorization: `Bearer ${tokens.accessToken}` + Authorization: `Bearer ${accessToken}` } }); const user = await response.json(); ``` -## Get email +## Get user email Add the `email` scope and use the [`/user/emails` endpoint](https://docs.github.com/en/rest/users/emails?apiVersion=2022-11-28#list-email-addresses-for-the-authenticated-user). ```ts -const url = await github.createAuthorizationURL(state, { - scopes: ["user:email"] -}); +const scopes = ["user:email"]; +const url = github.createAuthorizationURL(state, scopes); ``` ```ts const tokens = await github.validateAuthorizationCode(code); const response = await fetch("https://api.github.com/user/emails", { headers: { - Authorization: `Bearer ${tokens.accessToken}` + Authorization: `Bearer ${accessToken}` } }); const emails = await response.json(); diff --git a/docs/pages/providers/gitlab.md b/docs/pages/providers/gitlab.md index 171b4327..7bebf6be 100644 --- a/docs/pages/providers/gitlab.md +++ b/docs/pages/providers/gitlab.md @@ -4,24 +4,79 @@ title: "GitLab" # GitLab -For usage, see [OAuth 2.0 provider](/guides/oauth2). +OAuth 2.0 provider for GitLab. + +Also see the [OAuth 2.0](/guides/oauth2) guide. + +## Initialization + +The `domain` parameter should not include the protocol or path. Use `gitlab.com` for GitLab.com. ```ts import { GitLab } from "arctic"; -const gitlab = new GitLab(clientId, clientSecret, redirectURI, { - // optional - domain: "https://example.com" -}); +const gitlab = new GitLab("gitlab.com", clientId, clientSecret, redirectURI); +const gitlab = new GitLab(domain, clientId, clientSecret, redirectURI); ``` +## Create authorization URL + ```ts -const url: URL = await gitlab.createAuthorizationURL(state, { - // optional - scopes -}); -const tokens: GitLabTokens = await gitlab.validateAuthorizationCode(code); -const tokens: GitLabTokens = await gitlab.refreshAccessToken(refreshToken); +import { generateState } from "arctic"; + +const state = generateState(); +const scopes = ["read_user", "profile"]; +const url = gitlab.createAuthorizationURL(state, scopes); +``` + +## Validate authorization code + +`validateAuthorizationCode()` will either return an [`OAuth2Tokens`](/reference/main/OAuth2Tokens), or throw one of [`OAuth2RequestError`](/reference/main/OAuth2RequestError), [`ArcticFetchError`](/reference/main/ArcticFetchError), or a standard `Error` (parse errors). GitLab returns an access token, the access token expiration, and a refresh token. + +```ts +import { OAuth2RequestError, ArcticFetchError } from "arctic"; + +try { + const tokens = await gitlab.validateAuthorizationCode(code); + const accessToken = tokens.accessToken(); + const accessTokenExpiresAt = tokens.accessTokenExpiresAt(); + const refreshToken = tokens.refreshToken(); +} catch (e) { + if (e instanceof OAuth2RequestError) { + // Invalid authorization code, credentials, or redirect URI + const code = e.code; + // ... + } + if (e instanceof ArcticFetchError) { + // Failed to call `fetch()` + const cause = e.cause; + // ... + } + // Parse error +} +``` + +## Refresh access tokens + +Use `refreshAccessToken()` to get a new access token using a refresh token. This method's behavior is identical to `validateAuthorizationCode()`. + +```ts +import { OAuth2RequestError, ArcticFetchError } from "arctic"; + +try { + const tokens = await gitlab.refreshAccessToken(accessToken); + const accessToken = tokens.accessToken(); + const accessTokenExpiresAt = tokens.accessTokenExpiresAt(); + const refreshToken = tokens.refreshToken(); +} catch (e) { + if (e instanceof OAuth2RequestError) { + // Invalid authorization code, credentials, or redirect URI + } + if (e instanceof ArcticFetchError) { + // Failed to call `fetch()` + } + // Parse error +} ``` ## Get user profile @@ -29,17 +84,33 @@ const tokens: GitLabTokens = await gitlab.refreshAccessToken(refreshToken); Add the `read_user` scope and use the [`/user` endpoint](https://docs.gitlab.com/ee/api/users.html#list-current-user). ```ts -const url = await gitlab.createAuthorizationURL(state, { - scopes: ["read_user"] -}); +const scopes = ["read_user"]; +const url = gitlab.createAuthorizationURL(state, scopes); ``` ```ts -const tokens = await gitlab.validateAuthorizationCode(code); const response = await fetch("https://gitlab.com/api/v4/user", { headers: { - Authorization: `Bearer ${tokens.accessToken}` + Authorization: `Bearer ${accessToken}` } }); const user = await response.json(); ``` + +## Revoke tokens + +Use `revokeToken()` to revoke a token. This can throw the same errors as `validateAuthorizationCode()`. + +```ts +try { + await gitlab.revokeToken(token); +} catch (e) { + if (e instanceof OAuth2RequestError) { + // Invalid authorization code, credentials, or redirect URI + } + if (e instanceof ArcticFetchError) { + // Failed to call `fetch()` + } + // Parse error +} +``` diff --git a/docs/pages/providers/google.md b/docs/pages/providers/google.md index 01d0ca4f..42fa480f 100644 --- a/docs/pages/providers/google.md +++ b/docs/pages/providers/google.md @@ -4,9 +4,11 @@ title: "Google" # Google -Implements OpenID Connect. +OAuth 2.0 authorization code provider for Google. Only supports confidential clients. -For usage, see [OAuth 2.0 provider with PKCE](/guides/oauth2-pkce). +Also see [OAuth 2.0 with PKCE](/guides/oauth2-pkce). + +## Initialization ```ts import { Google } from "arctic"; @@ -14,42 +16,133 @@ import { Google } from "arctic"; const google = new Google(clientId, clientSecret, redirectURI); ``` +## Create authorization URL + ```ts -const url: URL = await google.createAuthorizationURL(state, codeVerifier, { - // optional - scopes // "openid" always included -}); -const tokens: GoogleTokens = await google.validateAuthorizationCode(code, codeVerifier); -const tokens: GoogleRefreshedTokens = await google.refreshAccessToken(refreshToken); +import { generateState, generateCodeVerifier } from "arctic"; + +const state = generateState(); +const codeVerifier = generateCodeVerifier(); +const scopes = ["openid", "profile"]; +const url = google.createAuthorizationURL(state, codeVerifier, scopes); ``` -## Get user profile +## Validate authorization code -Add the `profile` scope. Optionally add the `email` scope to get user email. +`validateAuthorizationCode()` will either return an [`OAuth2Tokens`](/reference/main/OAuth2Tokens), or throw one of [`OAuth2RequestError`](/reference/main/OAuth2RequestError), [`ArcticFetchError`](/reference/main/ArcticFetchError), or a standard `Error` (parse errors). Google will return an access token with an expiration. ```ts -const url = await google.createAuthorizationURL(state, codeVerifier, { - scopes: ["profile", "email"] -}); +import { OAuth2RequestError, ArcticFetchError } from "arctic"; + +try { + const tokens = await google.validateAuthorizationCode(code, codeVerifier); + const accessToken = tokens.accessToken(); + const accessTokenExpiresAt = tokens.accessTokenExpiresAt(); +} catch (e) { + if (e instanceof OAuth2RequestError) { + // Invalid authorization code, credentials, or redirect URI + const code = e.code; + // ... + } + if (e instanceof ArcticFetchError) { + // Failed to call `fetch()` + const cause = e.cause; + // ... + } + // Parse error +} ``` -Parse the ID token or use the `userinfo` endpoint. See [ID token claims](https://developers.google.com/identity/openid-connect/openid-connect#an-id-tokens-payload). +## OpenID Connect + +Use OpenID Connect with the `openid` scope to get the user's profile with an ID token or the `userinfo` endpoint. Arctic provides [`decodeIdToken()`](/reference/main/decodeIdToken) for decoding the token's payload. + +Also see [ID token claims](https://developers.google.com/identity/openid-connect/openid-connect#an-id-tokens-payload). ```ts +const scopes = ["openid"]; +const url = google.createAuthorizationURL(state, codeVerifier, scopes); +``` + +```ts +import { decodeIdToken } from "arctic"; + const tokens = await google.validateAuthorizationCode(code, codeVerifier); +const idToken = tokens.idToken(); +const claims = decodeIdToken(idToken); +``` + +```ts const response = await fetch("https://openidconnect.googleapis.com/v1/userinfo", { headers: { - Authorization: `Bearer ${tokens.accessToken}` + Authorization: `Bearer ${accessToken}` } }); const user = await response.json(); ``` -## Get refresh token +### Get user profile + +Make sure to add the `profile` scope to get the user profile and the `email` scope to get the user email. + +```ts +const scopes = ["openid", "profile", "email"]; +const url = google.createAuthorizationURL(state, codeVerifier, scopes); +``` + +## Refresh tokens -Set the `access_type` param to `offline`. You will only get the refresh token only in the first sign in. +Set the `access_type` parameter to `offline` to get refresh tokens. You will only get the refresh token on the user's first authentication. ```ts -const url = await google.createAuthorizationURL(); +const url = google.createAuthorizationURL(state, codeVerifier, scopes); url.searchParams.set("access_type", "offline"); ``` + +```ts +const tokens = await google.validateAuthorizationCode(code, codeVerifier); +const accessToken = tokens.accessToken(); +const accessTokenExpiresAt = tokens.accessTokenExpiresAt(); +if (tokens.hasRefreshToken()) { + const refreshToken = tokens.refreshToken(); + const refreshTokenExpiresAt = tokens.refreshTokenExpiresAt(); +} +``` + +Use `refreshAccessToken()` to get a new access token using a refresh token. This method's behavior is identical to `validateAuthorizationCode()`. Google will not provide a new refresh token after a token refresh. + +```ts +import { OAuth2RequestError, ArcticFetchError } from "arctic"; + +try { + const tokens = await google.refreshAccessToken(accessToken); + const accessToken = tokens.accessToken(); + const accessTokenExpiresAt = tokens.accessTokenExpiresAt(); +} catch (e) { + if (e instanceof OAuth2RequestError) { + // Invalid authorization code, credentials, or redirect URI + } + if (e instanceof ArcticFetchError) { + // Failed to call `fetch()` + } + // Parse error +} +``` + +## Revoke tokens + +Revoke tokens with `revokeToken()`. This can throw the same errors as `validateAuthorizationCode()`. + +```ts +try { + await google.revokeToken(token); +} catch (e) { + if (e instanceof OAuth2RequestError) { + // Invalid authorization code, credentials, or redirect URI + } + if (e instanceof ArcticFetchError) { + // Failed to call `fetch()` + } + // Parse error +} +``` diff --git a/docs/pages/providers/intuit.md b/docs/pages/providers/intuit.md index 36dff46d..567e0fd3 100644 --- a/docs/pages/providers/intuit.md +++ b/docs/pages/providers/intuit.md @@ -4,9 +4,11 @@ title: "Intuit" # Intuit -Implements OpenID Connect. +OAuth 2.0 provider for Intuit. -For usage, see [OAuth 2.0 provider](/guides/oauth2). +Also see the [OAuth 2.0](/guides/oauth2) guide. + +## Initialization ```ts import { Intuit } from "arctic"; @@ -14,33 +16,129 @@ import { Intuit } from "arctic"; const intuit = new Intuit(clientId, clientSecret, redirectURI); ``` +## Create authorization URL + ```ts -const url: URL = await intuit.createAuthorizationURL(state, { - // optional - scopes // "openid" always included -}); -const tokens: IntuitTokens = await intuit.validateAuthorizationCode(code); -const refreshedTokens: IntuitTokens = await intuit.refreshAccessToken(refreshToken); +import { generateState } from "arctic"; + +const state = generateState(); +const scopes = ["email", "activities.read"]; +const url = intuit.createAuthorizationURL(state, scopes); ``` -## Get user profile +## Validate authorization code -Add the `profile` scope. Optionally add the `email` scope to get user email, the `phone` scope to get user phone, or the `address` scope to get the user address. +`validateAuthorizationCode()` will either return an [`OAuth2Tokens`](/reference/main/OAuth2Tokens), or throw one of [`OAuth2RequestError`](/reference/main/OAuth2RequestError), [`ArcticFetchError`](/reference/main/ArcticFetchError), or a standard `Error` (parse errors). Intuit returns an access token, the access token expiration, and a refresh token. ```ts -const url = await intuit.createAuthorizationURL(state, codeVerifier, { - scopes: ["profile", "email", "phone", "address"] -}); +import { OAuth2RequestError, ArcticFetchError } from "arctic"; + +try { + const tokens = await intuit.validateAuthorizationCode(code); + const accessToken = tokens.accessToken(); + const accessTokenExpiresAt = tokens.accessTokenExpiresAt(); + const refreshToken = tokens.refreshToken(); +} catch (e) { + if (e instanceof OAuth2RequestError) { + // Invalid authorization code, credentials, or redirect URI + const code = e.code; + // ... + } + if (e instanceof ArcticFetchError) { + // Failed to call `fetch()` + const cause = e.cause; + // ... + } + // Parse error +} ``` -Parse the ID token or use the `userinfo` endpoint. See [ID token claims](https://developer.intuit.com/app/developer/qbo/docs/develop/authentication-and-authorization/openid-connect#obtaining-user-profile-information). +The refresh token expiration is returned as `x_refresh_token_expires_in`. + +```ts +const tokens = await intuit.validateAuthorizationCode(code); +if ( + "x_refresh_token_expires_in" in tokens.data && + typeof tokens.data.x_refresh_token_expires_in === "number" +) { + const refreshTokenExpiresIn = tokens.data.x_refresh_token_expires_in; +} +``` + +## OpenID Connect + +Use OpenID Connect with the `openid` scope to get the user's profile with an ID token or the `userinfo` endpoint. Arctic provides [`decodeIdToken()`](/reference/main/decodeIdToken) for decoding the token's payload. + +Also see [ID token claims](https://developer.intuit.com/app/developer/qbo/docs/develop/authentication-and-authorization/openid-connect#obtaining-user-profile-information). + +```ts +const scopes = ["openid"]; +const url = intuit.createAuthorizationURL(state, scopes); +``` + +```ts +import { decodeIdToken } from "arctic"; + +const tokens = await intuit.validateAuthorizationCode(code); +const idToken = tokens.idToken(); +const claims = decodeIdToken(idToken); +``` ```ts -const tokens = await intuit.validateAuthorizationCode(code, codeVerifier); const response = await fetch("https://accounts.platform.intuit.com/v1/openid_connect/userinfo", { headers: { - Authorization: `Bearer ${tokens.accessToken}` + Authorization: `Bearer ${accessToken}` } }); const user = await response.json(); ``` + +### Get user profile + +Make sure to add the `profile` scope to get the user profile and the `email` scope to get the user email. + +```ts +const scopes = ["openid", "profile", "email"]; +const url = intuit.createAuthorizationURL(state, scopes); +``` + +## Refresh access tokens + +Use `refreshAccessToken()` to get a new access token using a refresh token. The returned values are the same as authorization code validation. This method also returns `OAuth2Tokens` and throws the same errors as `validateAuthorizationCode()` + +```ts +import { OAuth2RequestError, ArcticFetchError } from "arctic"; + +try { + const tokens = await intuit.refreshAccessToken(accessToken); + const accessToken = tokens.accessToken(); + const accessTokenExpiresAt = tokens.accessTokenExpiresAt(); + const refreshToken = tokens.refreshToken(); +} catch (e) { + if (e instanceof OAuth2RequestError) { + // Invalid authorization code, credentials, or redirect URI + } + if (e instanceof ArcticFetchError) { + // Failed to call `fetch()` + } + // Parse error +} +``` + +## Revoke tokens + +Use `revokeToken()` to revoke a token. This can throw the same errors as `validateAuthorizationCode()`. + +```ts +try { + await intuit.revokeToken(token); +} catch (e) { + if (e instanceof OAuth2RequestError) { + // Invalid authorization code, credentials, or redirect URI + } + if (e instanceof ArcticFetchError) { + // Failed to call `fetch()` + } + // Parse error +} +``` diff --git a/docs/pages/providers/kakao.md b/docs/pages/providers/kakao.md index ecbe6f19..9eebfec9 100644 --- a/docs/pages/providers/kakao.md +++ b/docs/pages/providers/kakao.md @@ -4,7 +4,11 @@ title: "Kakao" # Kakao -For usage, see [OAuth 2.0 provider](/guides/oauth2). +OAuth 2.0 provider for Kakao. + +Also see the [OAuth 2.0](/guides/oauth2) guide. + +## Initialization ```ts import { Kakao } from "arctic"; @@ -12,13 +16,66 @@ import { Kakao } from "arctic"; const kakao = new Kakao(clientId, clientSecret, redirectURI); ``` +## Create authorization URL + ```ts -const url: URL = await kakao.createAuthorizationURL(state, { - // optional - scopes -}); -const tokens: KakaoTokens = await kakao.validateAuthorizationCode(code); -const tokens: KakaoTokens = await kakao.refreshAccessToken(refreshToken); +import { generateState } from "arctic"; + +const state = generateState(); +const scopes = ["account_email", "profile"]; +const url = kakao.createAuthorizationURL(state, scopes); +``` + +## Validate authorization code + +`validateAuthorizationCode()` will either return an [`OAuth2Tokens`](/reference/main/OAuth2Tokens), or throw one of [`OAuth2RequestError`](/reference/main/OAuth2RequestError), [`ArcticFetchError`](/reference/main/ArcticFetchError), or a standard `Error` (parse errors). Kakao returns an access token, a refresh token, and their expiration. + +```ts +import { OAuth2RequestError, ArcticFetchError } from "arctic"; + +try { + const tokens = await kakao.validateAuthorizationCode(code); + const accessToken = tokens.accessToken(); + const accessTokenExpiresAt = tokens.accessTokenExpiresAt(); + const refreshToken = tokens.refreshToken(); + const refreshTokenExpiresAt = tokens.refreshTokenExpiresAt(); +} catch (e) { + if (e instanceof OAuth2RequestError) { + // Invalid authorization code, credentials, or redirect URI + const code = e.code; + // ... + } + if (e instanceof ArcticFetchError) { + // Failed to call `fetch()` + const cause = e.cause; + // ... + } + // Parse error +} +``` + +## Refresh access tokens + +Use `refreshAccessToken()` to get a new access token using a refresh token. Kakao returns the same values as during the authorization code validation. This method also returns `OAuth2Tokens` and throws the same errors as `validateAuthorizationCode()` + +```ts +import { OAuth2RequestError, ArcticFetchError } from "arctic"; + +try { + const tokens = await kakao.refreshAccessToken(accessToken); + const accessToken = tokens.accessToken(); + const accessTokenExpiresAt = tokens.accessTokenExpiresAt(); + const refreshToken = tokens.refreshToken(); + const refreshTokenExpiresAt = tokens.refreshTokenExpiresAt(); +} catch (e) { + if (e instanceof OAuth2RequestError) { + // Invalid authorization code, credentials, or redirect URI + } + if (e instanceof ArcticFetchError) { + // Failed to call `fetch()` + } + // Parse error +} ``` ## Get user profile @@ -26,10 +83,9 @@ const tokens: KakaoTokens = await kakao.refreshAccessToken(refreshToken); Use the [`/user/me` endpoint](https://developers.kakao.com/docs/latest/en/kakaologin/rest-api#req-user-info). ```ts -const tokens = await kakao.validateAuthorizationCode(code); const response = await fetch("https://kapi.kakao.com/v2/user/me", { headers: { - Authorization: `Bearer ${tokens.accessToken}` + Authorization: `Bearer ${accessToken}` } }); const user = await response.json(); diff --git a/docs/pages/providers/keycloak.md b/docs/pages/providers/keycloak.md index a87c368b..62af706a 100644 --- a/docs/pages/providers/keycloak.md +++ b/docs/pages/providers/keycloak.md @@ -1,48 +1,113 @@ --- -title: "Keycloak" +title: "KeyCloak" --- -# Keycloak +# Okta -Implements OpenID Connect. +OAuth 2.0 provider for KeyCloak. -For usage, see [OAuth 2.0 provider with PKCE](/guides/oauth2-pkce). +Also see the [OAuth 2.0 with PKCE](/guides/oauth2-pkce) guide. -```ts -import { Keycloak } from "arctic"; +## Initialization + +The realm URL should not include trailing slashes. -const realmURL = "https://example.com/realms/xxx"; +```ts +import { KeyCloak } from "arctic"; -const keycloak = new Keycloak(realmURL, clientId, clientSecret, redirectURI); +const realmURL = "https://auth.example.com/realms/myrealm" +const keycloak = new KeyCloak(realmURL, clientId, clientSecret, redirectURI); ``` +## Create authorization URL + ```ts -const url: URL = await keycloak.createAuthorizationURL(state, codeVerifier, { - // optional - scopes -}); -const tokens: KeycloakTokens = await keycloak.validateAuthorizationCode(code); -const tokens: KeycloakTokens = await keycloak.refreshAccessToken(refreshToken); +import { generateState, generateCodeVerifier } from "arctic"; + +const state = generateState(); +const codeVerifier = generateCodeVerifier(); +const scopes = ["openid", "profile"]; +const url = keycloak.createAuthorizationURL(state, codeVerifier, scopes); ``` -## Get user profile +## Validate authorization code -Add the `profile` scope. Optionally add the `email` scope to get user email. +`validateAuthorizationCode()` will either return an [`OAuth2Tokens`](/reference/main/OAuth2Tokens), or throw one of [`OAuth2RequestError`](/reference/main/OAuth2RequestError), [`ArcticFetchError`](/reference/main/ArcticFetchError), or a standard `Error` (parse errors). Actual values returned by KeyCloak depends on your configuration and version. ```ts -const url = await keycloak.createAuthorizationURL(state, codeVerifier, { - scopes: ["profile", "email"] -}); +import { OAuth2RequestError, ArcticFetchError } from "arctic"; + +try { + const tokens = await keycloak.validateAuthorizationCode(code, codeVerifier); + const accessToken = tokens.accessToken(); + const accessTokenExpiresAt = tokens.accessTokenExpiresAt(); + const refreshToken = tokens.refreshToken(); +} catch (e) { + if (e instanceof OAuth2RequestError) { + // Invalid authorization code, credentials, or redirect URI + const code = e.code; + // ... + } + if (e instanceof ArcticFetchError) { + // Failed to call `fetch()` + const cause = e.cause; + // ... + } + // Parse error +} ``` -Parse the ID token or use the `userinfo` endpoint. +## OpenID Connect + +Use OpenID Connect with the `openid` scope to get the user's profile with an ID token or the `userinfo` endpoint. Arctic provides [`decodeIdToken()`](/reference/main/decodeIdToken) for decoding the token's payload. ```ts +const scopes = ["openid"]; +const url = keycloak.createAuthorizationURL(state, codeVerifier, scopes); +``` + +```ts +import { decodeIdToken } from "arctic"; + const tokens = await keycloak.validateAuthorizationCode(code, codeVerifier); -const response = await fetch("https://example.com/realms/xxx/protocol/openid-connect/userinfo", { - headers: { - Authorization: `Bearer ${tokens.accessToken}` +const idToken = tokens.idToken(); +const claims = decodeIdToken(idToken); +``` + +## Refresh access tokens + +Use `refreshAccessToken()` to get a new access token using a refresh token. This method also returns `OAuth2Tokens` and throws the same errors as `validateAuthorizationCode()`. + +```ts +import { OAuth2RequestError, ArcticFetchError } from "arctic"; + +try { + const tokens = await keycloak.refreshAccessToken(accessToken); +} catch (e) { + if (e instanceof OAuth2RequestError) { + // Invalid authorization code, credentials, or redirect URI + } + if (e instanceof ArcticFetchError) { + // Failed to call `fetch()` + } + // Parse error +} +``` + +## Revoke tokens + +Use `revokeToken()` to revoke a token. This can throw the same errors as `validateAuthorizationCode()`. + +```ts +try { + await keycloak.revokeToken(token); +} catch (e) { + if (e instanceof OAuth2RequestError) { + // Invalid authorization code, credentials, or redirect URI + } + if (e instanceof ArcticFetchError) { + // Failed to call `fetch()` } -}); -const user = await response.json(); + // Parse error +} ``` diff --git a/docs/pages/providers/lichess.md b/docs/pages/providers/lichess.md index e72e03ae..6bf11014 100644 --- a/docs/pages/providers/lichess.md +++ b/docs/pages/providers/lichess.md @@ -4,12 +4,53 @@ title: "Lichess" # Lichess -For usage, see [OAuth 2.0 provider with PKCE](/guides/oauth2-pkce). +OAuth 2.0 provider for Lichess. + +Also see the [OAuth 2.0 with PKCE](/guides/oauth2-pkce) guide. + +## Initialization ```ts import { Lichess } from "arctic"; -export const lichess = new Lichess(clientId, redirectURI); +const lichess = new Lichess(clientId, redirectURI); +``` + +## Create authorization URL + +```ts +import { generateState, generateCodeVerifier } from "arctic"; + +const state = generateState(); +const codeVerifier = generateCodeVerifier(); +const scopes = ["challenge:read", "challenge:write"]; +const url = lichess.createAuthorizationURL(state, codeVerifier, scopes); +``` + +## Validate authorization code + +`validateAuthorizationCode()` will either return an [`OAuth2Tokens`](/reference/main/OAuth2Tokens), or throw one of [`OAuth2RequestError`](/reference/main/OAuth2RequestError), [`ArcticFetchError`](/reference/main/ArcticFetchError), or a standard `Error` (parse errors). Lichess returns an access token and its expiration. + +```ts +import { OAuth2RequestError, ArcticFetchError } from "arctic"; + +try { + const tokens = await lichess.validateAuthorizationCode(code, codeVerifier); + const accessToken = tokens.accessToken(); + const accessTokenExpiresAt = tokens.accessTokenExpiresAt(); +} catch (e) { + if (e instanceof OAuth2RequestError) { + // Invalid authorization code, credentials, or redirect URI + const code = e.code; + // ... + } + if (e instanceof ArcticFetchError) { + // Failed to call `fetch()` + const cause = e.cause; + // ... + } + // Parse error +} ``` ## Get user profile @@ -17,10 +58,9 @@ export const lichess = new Lichess(clientId, redirectURI); Use the [/api/account](https://lichess.org/api#tag/Account/operation/accountMe) endpoint ```ts -const tokens = await lichess.validateAuthorizationCode(code); const lichessUserResponse = await fetch("https://lichess.org/api/account", { headers: { - Authorization: `Bearer ${tokens.accessToken}` + Authorization: `Bearer ${tokens}` } }); const user = await lichessUserResponse.json(); @@ -31,17 +71,15 @@ const user = await lichessUserResponse.json(); Add the `email:read` scope and use the [/api/account/email](https://lichess.org/api#tag/Account/operation/accountEmail) endpoint ```ts -const url = await lichess.createAuthorizationURL(state, codeVerifier, { - scopes: ["email:read"] -}); +const scopes = ["email:read"]; +const url = lichess.createAuthorizationURL(state, codeVerifier, scopes); ``` ```ts -const tokens = await lichess.validateAuthorizationCode(code); -const liichessEmailResponse = await fetch("https://lichess.org/api/account/email", { +const response = await fetch("https://lichess.org/api/account/email", { headers: { - Authorization: `Bearer ${tokens.accessToken}` + Authorization: `Bearer ${accessToken}` } }); -const { email } = await lichessUserResponse.json(); +const email = await response.json(); ``` diff --git a/docs/pages/providers/line.md b/docs/pages/providers/line.md index 40a66127..2e3c2d87 100644 --- a/docs/pages/providers/line.md +++ b/docs/pages/providers/line.md @@ -4,9 +4,11 @@ title: "Line" # Line -Implements OpenID Connect. +OAuth 2.0 provider for Line. -For usage, see [OAuth 2.0 provider with PKCE](/guides/oauth2-pkce). +Also see the [OAuth 2.0](/guides/oauth2) guide. + +## Initialization ```ts import { Line } from "arctic"; @@ -14,35 +16,99 @@ import { Line } from "arctic"; const line = new Line(clientId, clientSecret, redirectURI); ``` +## Create authorization URL + ```ts -const url: URL = await line.createAuthorizationURL(state, codeVerifier, { - // optional - scopes // "openid" always included -}); -const tokens: LineTokens = await line.validateAuthorizationCode(code, codeVerifier); -const tokens: LineRefreshedTokens = await line.refreshAccessToken(refreshToken); +import { generateState, generateCodeVerifier } from "arctic"; + +const state = generateState(); +const codeVerifier = generateCodeVerifier(); +const scopes = ["openid", "profile"]; +const url = line.createAuthorizationURL(state, codeVerifier, scopes); ``` -## Get user profile +## Validate authorization code -Add the `profile` scope. Optionally add the `email` scope to get user email. +`validateAuthorizationCode()` will either return an [`OAuth2Tokens`](/reference/main/OAuth2Tokens), or throw one of [`OAuth2RequestError`](/reference/main/OAuth2RequestError), [`ArcticFetchError`](/reference/main/ArcticFetchError), or a standard `Error` (parse errors). Line returns an access token, the access token expiration, and a refresh token. ```ts -const url = await line.createAuthorizationURL(state, codeVerifier, { - scopes: ["profile", "email"] -}); +import { OAuth2RequestError, ArcticFetchError } from "arctic"; + +try { + const tokens = await line.validateAuthorizationCode(code, codeVerifier); + const accessToken = tokens.accessToken(); + const accessTokenExpiresAt = tokens.accessTokenExpiresAt(); + const refreshToken = tokens.refreshToken(); +} catch (e) { + if (e instanceof OAuth2RequestError) { + // Invalid authorization code, credentials, or redirect URI + const code = e.code; + // ... + } + if (e instanceof ArcticFetchError) { + // Failed to call `fetch()` + const cause = e.cause; + // ... + } + // Parse error +} ``` -Parse the ID token or use the `userinfo` endpoint. See [ID token claims](https://developers.line.biz/en/docs/line-login/verify-id-token/#signature). +## Refresh access tokens + +Use `refreshAccessToken()` to get a new access token using a refresh token. Line only returns a new access token and its expiration. This method also returns `OAuth2Tokens` and throws the same errors as `validateAuthorizationCode()` ```ts +import { OAuth2RequestError, ArcticFetchError } from "arctic"; + +try { + const tokens = await line.refreshAccessToken(accessToken); + const accessToken = tokens.accessToken(); + const accessTokenExpiresAt = tokens.accessTokenExpiresAt(); +} catch (e) { + if (e instanceof OAuth2RequestError) { + // Invalid authorization code, credentials, or redirect URI + } + if (e instanceof ArcticFetchError) { + // Failed to call `fetch()` + } + // Parse error +} +``` + +## OpenID Connect + +Use OpenID Connect with the `openid` scope to get the user's profile with an ID token or the `userinfo` endpoint. Arctic provides [`decodeIdToken()`](/reference/main/decodeIdToken) for decoding the token's payload. + +```ts +const scopes = ["openid"]; +const url = line.createAuthorizationURL(state, codeVerifier, scopes); +``` + +```ts +import { decodeIdToken } from "arctic"; + const tokens = await line.validateAuthorizationCode(code, codeVerifier); +const idToken = tokens.idToken(); +const claims = decodeIdToken(idToken); +``` + +```ts const response = await fetch("https://api.line.me/oauth2/v2.1/userinfo", { headers: { - Authorization: `Bearer ${tokens.accessToken}` + Authorization: `Bearer ${accessToken}` } }); const user = await response.json(); ``` +### Get user profile + +Make sure to add the `profile` scope to get the user profile and the `email` scope to get the user email. + +```ts +const scopes = ["openid", "profile", "email"]; +const url = line.createAuthorizationURL(state, codeVerifier, scopes); +``` + Or, alternatively use the [`/profile` endpoint](https://developers.line.biz/en/reference/line-login/#get-user-profile). diff --git a/docs/pages/providers/linear.md b/docs/pages/providers/linear.md index 072d427b..54661e60 100644 --- a/docs/pages/providers/linear.md +++ b/docs/pages/providers/linear.md @@ -4,7 +4,11 @@ title: "Linear" # Linear -For usage, see [OAuth 2.0 provider](/guides/oauth2). +OAuth 2.0 provider for Linear. + +Also see the [OAuth 2.0](/guides/oauth2) guide. + +## Initialization ```ts import { Linear } from "arctic"; @@ -12,12 +16,42 @@ import { Linear } from "arctic"; const linear = new Linear(clientId, clientSecret, redirectURI); ``` +## Create authorization URL + +**The `read` scope must always be included.** + ```ts -const url: URL = await linear.createAuthorizationURL(state, { - // optional - scopes // "read" always included -}); -const tokens: LinearTokens = await linear.validateAuthorizationCode(code); +import { generateState } from "arctic"; + +const state = generateState(); +const scopes = ["read", "write"]; +const url = linear.createAuthorizationURL(state, scopes); +``` + +## Validate authorization code + +`validateAuthorizationCode()` will either return an [`OAuth2Tokens`](/reference/main/OAuth2Tokens), or throw one of [`OAuth2RequestError`](/reference/main/OAuth2RequestError), [`ArcticFetchError`](/reference/main/ArcticFetchError), or a standard `Error` (parse errors). Linear will return an access token with an expiration. + +```ts +import { OAuth2RequestError, ArcticFetchError } from "arctic"; + +try { + const tokens = await linear.validateAuthorizationCode(code); + const accessToken = tokens.accessToken(); + const accessTokenExpiresAt = tokens.accessTokenExpiresAt(); +} catch (e) { + if (e instanceof OAuth2RequestError) { + // Invalid authorization code, credentials, or redirect URI + const code = e.code; + // ... + } + if (e instanceof ArcticFetchError) { + // Failed to call `fetch()` + const cause = e.cause; + // ... + } + // Parse error +} ``` ## Get user profile @@ -25,13 +59,12 @@ const tokens: LinearTokens = await linear.validateAuthorizationCode(code); Use Linear's [GraphQL API](https://developers.linear.app/docs/graphql/working-with-the-graphql-api). ```ts -const tokens = await linear.validateAuthorizationCode(code); const response = await fetch("https://api.linear.app/graphql", { method: "POST", body: `{ "query": "{ viewer { id name } }" }`, headers: { "Content-Type": "application/json" - Authorization: `Bearer ${tokens.accessToken}` + Authorization: `Bearer ${accessToken}` } }); const user = await response.json(); diff --git a/docs/pages/providers/linkedin.md b/docs/pages/providers/linkedin.md index 24880f06..e270be96 100644 --- a/docs/pages/providers/linkedin.md +++ b/docs/pages/providers/linkedin.md @@ -4,43 +4,113 @@ title: "LinkedIn" # LinkedIn -Implements OpenID Connect. +OAuth 2.0 provider for LinkedIn. -For usage, see [OAuth 2.0 provider](/guides/oauth2). +Also see the [OAuth 2.0](/guides/oauth2) guide. + +## Initialization + +The domain should not include the protocol or path. ```ts import { LinkedIn } from "arctic"; -const linkedIn = new LinkedIn(clientId, clientSecret, redirectURI); +const linkedin = new LinkedIn(clientId, clientSecret, redirectURI); ``` +## Create authorization URL + ```ts -const url: URL = await linkedIn.createAuthorizationURL(state, { - // optional - scopes // "openid" always included -}); -const tokens: LinkedInTokens = await linkedIn.validateAuthorizationCode(code); -const tokens: LinkedInTokens = await linkedIn.refreshAccessToken(refreshToken); +import { generateState } from "arctic"; + +const state = generateState(); +const scopes = ["openid", "profile"]; +const url = linkedin.createAuthorizationURL(state, scopes); ``` -## Get user profile +## Validate authorization code -Add the `profile` scopes, and optionally add the `email` scope to get user email. +`validateAuthorizationCode()` will either return an [`OAuth2Tokens`](/reference/main/OAuth2Tokens), or throw one of [`OAuth2RequestError`](/reference/main/OAuth2RequestError), [`ArcticFetchError`](/reference/main/ArcticFetchError), or a standard `Error` (parse errors). LinkedIn returns an access token, a refresh token, and their expiration. ```ts -const url = await linkedIn.createAuthorizationURL(state, { - scopes: ["profile", "email"] -}); +import { OAuth2RequestError, ArcticFetchError } from "arctic"; + +try { + const tokens = await linkedin.validateAuthorizationCode(code); + const accessToken = tokens.accessToken(); + const accessTokenExpiresAt = tokens.accessTokenExpiresAt(); + const refreshToken = tokens.refreshToken(); + const refreshTokenExpiresAt = tokens.refreshTokenExpiresAt(); +} catch (e) { + if (e instanceof OAuth2RequestError) { + // Invalid authorization code, credentials, or redirect URI + const code = e.code; + // ... + } + if (e instanceof ArcticFetchError) { + // Failed to call `fetch()` + const cause = e.cause; + // ... + } + // Parse error +} +``` + +## Refresh access tokens + +Use `refreshAccessToken()` to get a new access token using a refresh token. LinkedIn returns the same values as during the authorization code validation. This method also returns `OAuth2Tokens` and throws the same errors as `validateAuthorizationCode()` + +```ts +import { OAuth2RequestError, ArcticFetchError } from "arctic"; + +try { + const tokens = await linkedin.refreshAccessToken(accessToken); + const accessToken = tokens.accessToken(); + const accessTokenExpiresAt = tokens.accessTokenExpiresAt(); + const refreshToken = tokens.refreshToken(); + const refreshTokenExpiresAt = tokens.refreshTokenExpiresAt(); +} catch (e) { + if (e instanceof OAuth2RequestError) { + // Invalid authorization code, credentials, or redirect URI + } + if (e instanceof ArcticFetchError) { + // Failed to call `fetch()` + } + // Parse error +} ``` -Parse the ID token or use the `userinfo` endpoint. See [ID token claims](https://learn.microsoft.com/en-us/linkedin/consumer/integrations/self-serve/sign-in-with-linkedin-v2#response-body-schema). +## OpenID Connect + +Use OpenID Connect with the `openid` scope to get the user's profile with an ID token or the `userinfo` endpoint. Arctic provides [`decodeIdToken()`](/reference/main/decodeIdToken) for decoding the token's payload. + +```ts +const scopes = ["openid"]; +const url = linkedin.createAuthorizationURL(state, codeVerifier, scopes); +``` + +```ts +import { decodeIdToken } from "arctic"; + +const tokens = await linkedin.validateAuthorizationCode(code, codeVerifier); +const idToken = tokens.idToken(); +const claims = decodeIdToken(idToken); +``` ```ts -const tokens = await linkedIn.validateAuthorizationCode(code); const response = await fetch("https://api.linkedin.com/v2/userinfo", { headers: { - Authorization: `Bearer ${tokens.accessToken}` + Authorization: `Bearer ${accessToken}` } }); const user = await response.json(); ``` + +### Get user profile + +Make sure to add the `profile` scope to get the user profile and the `email` scope to get the user email. + +```ts +const scopes = ["openid", "profile", "email"]; +const url = linkedin.createAuthorizationURL(state, codeVerifier, scopes); +``` diff --git a/docs/pages/providers/microsoft-entra-id.md b/docs/pages/providers/microsoft-entra-id.md index 9f2760e8..322bc0c6 100644 --- a/docs/pages/providers/microsoft-entra-id.md +++ b/docs/pages/providers/microsoft-entra-id.md @@ -4,43 +4,114 @@ title: "Microsoft Entra ID" # Microsoft Entra ID -Implements OpenID Connect. By default, `nonce` is set to `_`. +OAuth 2.0 provider for Microsoft Entra ID. -For usage, see [OAuth 2.0 provider with PKCE](/guides/oauth2-pkce). +Also see the [OAuth 2.0](/guides/oauth2) guide. + +## Initialization + +See [Endpoints](https://learn.microsoft.com/en-us/entra/identity-platform/v2-protocols#endpoints) for more on the `tenant` parameter. ```ts import { MicrosoftEntraId } from "arctic"; -const entraId = new MicrosoftEntraId(tenantId, clientId, clientSecret, redirectURI); +const entraId = new MicrosoftEntraId(tenant, clientId, clientSecret, redirectURI); ``` +## Create authorization URL + ```ts -const url: URL = await entraId.createAuthorizationURL(state, codeVerifier, { - // optional - scopes // "openid" always included -}); -const tokens: MicrosoftEntraIdTokens = await entraId.validateAuthorizationCode(code, codeVerifier); -const tokens: MicrosoftEntraIdTokens = await entraId.refreshAccessToken(refreshToken); +import { generateState, generateCodeVerifier } from "arctic"; + +const state = generateState(); +const codeVerifier = generateCodeVerifier(); +const scopes = ["openid", "profile"]; +const url = entraId.createAuthorizationURL(state, codeVerifier, scopes); ``` -## Get user profile +## Validate authorization code -Add the `profile` scope. Optionally add the `email` scope to get user email. +`validateAuthorizationCode()` will either return an [`OAuth2Tokens`](/reference/main/OAuth2Tokens), or throw one of [`OAuth2RequestError`](/reference/main/OAuth2RequestError), [`ArcticFetchError`](/reference/main/ArcticFetchError), or a standard `Error` (parse errors). Entra ID returns an access token, the access token expiration, and a refresh token. ```ts -const url = await entraId.createAuthorizationURL(state, codeVerifier, { - scopes: ["profile", "email"] -}); +import { OAuth2RequestError, ArcticFetchError } from "arctic"; + +try { + const tokens = await entraId.validateAuthorizationCode(code, codeVerifier); + const accessToken = tokens.accessToken(); + const accessTokenExpiresAt = tokens.accessTokenExpiresAt(); + const refreshToken = tokens.refreshToken(); +} catch (e) { + if (e instanceof OAuth2RequestError) { + // Invalid authorization code, credentials, or redirect URI + const code = e.code; + // ... + } + if (e instanceof ArcticFetchError) { + // Failed to call `fetch()` + const cause = e.cause; + // ... + } + // Parse error +} +``` + +## Refresh access tokens + +Use `refreshAccessToken()` to get a new access token using a refresh token. Entra ID returns the same values as during the authorization code validation. This method also returns `OAuth2Tokens` and throws the same errors as `validateAuthorizationCode()` + +```ts +import { OAuth2RequestError, ArcticFetchError } from "arctic"; + +try { + const tokens = await entraId.refreshAccessToken(accessToken); + const accessToken = tokens.accessToken(); + const accessTokenExpiresAt = tokens.accessTokenExpiresAt(); +} catch (e) { + if (e instanceof OAuth2RequestError) { + // Invalid authorization code, credentials, or redirect URI + } + if (e instanceof ArcticFetchError) { + // Failed to call `fetch()` + } + // Parse error +} ``` -Parse the ID token or use the `userinfo` endpoint. See [ID token claims](https://learn.microsoft.com/en-us/entra/identity-platform/id-token-claims-reference). +## OpenID Connect + +Use OpenID Connect with the `openid` scope to get the user's profile with an ID token or the `userinfo` endpoint. The `nonce` parameter is required by Entra ID to use OpenID. Arctic provides [`decodeIdToken()`](/reference/main/decodeIdToken) for decoding the token's payload. + +```ts +const scopes = ["openid"]; +const url = entraId.createAuthorizationURL(state, codeVerifier, scopes); +// The nonce should be unique to each request similar to state. +// However, nonce can just be "_" here since it isn't useful for server-based OAuth. +url.searchParams.set("nonce", nonce); +``` ```ts +import { decodeIdToken } from "arctic"; + const tokens = await entraId.validateAuthorizationCode(code, codeVerifier); +const idToken = tokens.idToken(); +const claims = decodeIdToken(idToken); +``` + +```ts const response = await fetch("https://graph.microsoft.com/oidc/userinfo", { headers: { - Authorization: `Bearer ${tokens.accessToken}` + Authorization: `Bearer ${accessToken}` } }); const user = await response.json(); ``` + +### Get user profile + +Make sure to add the `profile` scope to get the user profile and the `email` scope to get the user email. + +```ts +const scopes = ["openid", "profile", "email"]; +const url = entraId.createAuthorizationURL(state, codeVerifier, scopes); +``` diff --git a/docs/pages/providers/myanimelist.md b/docs/pages/providers/myanimelist.md index d894a3a7..41e3e690 100644 --- a/docs/pages/providers/myanimelist.md +++ b/docs/pages/providers/myanimelist.md @@ -4,23 +4,76 @@ title: "MyAnimeList" # MyAnimeList -Implements OpenID Connect. +OAuth 2.0 provider for MyAnimeList. -For usage, see [OAuth 2.0 provider with PKCE](/guides/oauth2-pkce). +Also see the [OAuth 2.0](/guides/oauth2) guide. + +## Initialization ```ts import { MyAnimeList } from "arctic"; -const mal = new MyAnimeList(clientId, clientSecret, { - // optional - redirectURI // only required if you have multiple URIs registered -}); +const mal = new MyAnimeList(clientId, clientSecret, redirectURI); +``` + +## Create authorization URL + +```ts +import { generateState, generateCodeVerifier } from "arctic"; + +const state = generateState(); +const codeVerifier = generateCodeVerifier(); +const url = mal.createAuthorizationURL(state, codeVerifier); +``` + +## Validate authorization code + +`validateAuthorizationCode()` will either return an [`OAuth2Tokens`](/reference/main/OAuth2Tokens), or throw one of [`OAuth2RequestError`](/reference/main/OAuth2RequestError), [`ArcticFetchError`](/reference/main/ArcticFetchError), or a standard `Error` (parse errors). MyAnimeList returns an access token, the access token expiration, and a refresh token. + +```ts +import { OAuth2RequestError, ArcticFetchError } from "arctic"; + +try { + const tokens = await mal.validateAuthorizationCode(code, codeVerifier); + const accessToken = tokens.accessToken(); + const accessTokenExpiresAt = tokens.accessTokenExpiresAt(); + const refreshToken = tokens.refreshToken(); +} catch (e) { + if (e instanceof OAuth2RequestError) { + // Invalid authorization code, credentials, or redirect URI + const code = e.code; + // ... + } + if (e instanceof ArcticFetchError) { + // Failed to call `fetch()` + const cause = e.cause; + // ... + } + // Parse error +} ``` +## Refresh access tokens + +Use `refreshAccessToken()` to get a new access token using a refresh token. MyAnimeList returns the same values as during the authorization code validation. This method also returns `OAuth2Tokens` and throws the same errors as `validateAuthorizationCode()` + ```ts -const url: URL = await mal.createAuthorizationURL(state, codeVerifier); -const tokens: MyAnimeListTokens = await mal.validateAuthorizationCode(code, codeVerifier); -const tokens: MyAnimeListTokens = await mal.refreshAccessToken(refreshToken); +import { OAuth2RequestError, ArcticFetchError } from "arctic"; + +try { + const tokens = await mal.refreshAccessToken(accessToken); + const accessToken = tokens.accessToken(); + const accessTokenExpiresAt = tokens.accessTokenExpiresAt(); + const refreshToken = tokens.refreshToken(); +} catch (e) { + if (e instanceof OAuth2RequestError) { + // Invalid authorization code, credentials, or redirect URI + } + if (e instanceof ArcticFetchError) { + // Failed to call `fetch()` + } + // Parse error +} ``` ## Get user profile @@ -28,10 +81,9 @@ const tokens: MyAnimeListTokens = await mal.refreshAccessToken(refreshToken); Use the [`/users` endpoint](https://myanimelist.net/apiconfig/references/api/v2#operation/users_user_id_get). ```ts -const tokens = await mal.validateAuthorizationCode(code, codeVerifier); -const response = await fetch("https://api.myanimelist.net/v2/users/@me, { +const response = await fetch("https://api.myanimelist.net/v2/users/@me", { headers: { - Authorization: `Bearer ${tokens.accessToken}` + Authorization: `Bearer ${accessToken}` } }); const user = await response.json(); diff --git a/docs/pages/providers/notion.md b/docs/pages/providers/notion.md index bc3772c6..bc25ccc2 100644 --- a/docs/pages/providers/notion.md +++ b/docs/pages/providers/notion.md @@ -4,7 +4,11 @@ title: "Notion" # Notion -For usage, see [OAuth 2.0 provider](/guides/oauth2). +OAuth 2.0 provider for Notion. + +Also see the [OAuth 2.0](/guides/oauth2) guide. + +## Initialization ```ts import { Notion } from "arctic"; @@ -12,9 +16,38 @@ import { Notion } from "arctic"; const notion = new Notion(clientId, clientSecret, redirectURI); ``` +## Create authorization URL + +```ts +import { generateState } from "arctic"; + +const state = generateState(); +const url = notion.createAuthorizationURL(state); +``` + +## Validate authorization code + +`validateAuthorizationCode()` will either return an [`OAuth2Tokens`](/reference/main/OAuth2Tokens), or throw one of [`OAuth2RequestError`](/reference/main/OAuth2RequestError), [`ArcticFetchError`](/reference/main/ArcticFetchError), or a standard `Error` (parse errors). Notion will only return an access token (no expiration). + ```ts -const url: URL = await notion.createAuthorizationURL(state); -const tokens: NotionTokens = await notion.validateAuthorizationCode(code); +import { OAuth2RequestError, ArcticFetchError } from "arctic"; + +try { + const tokens = await notion.validateAuthorizationCode(code); + const accessToken = tokens.accessToken(); +} catch (e) { + if (e instanceof OAuth2RequestError) { + // Invalid authorization code, credentials, or redirect URI + const code = e.code; + // ... + } + if (e instanceof ArcticFetchError) { + // Failed to call `fetch()` + const cause = e.cause; + // ... + } + // Parse error +} ``` ## Get user profile @@ -22,10 +55,9 @@ const tokens: NotionTokens = await notion.validateAuthorizationCode(code); Use the [`/users/me` endpoint](https://developers.notion.com/reference/get-self). ```ts -const tokens = await notion.validateAuthorizationCode(code); const response = await fetch("https://api.notion.com/v1/users/me", { headers: { - Authorization: `Bearer ${tokens.accessToken}` + Authorization: `Bearer ${accessToken}` } }); const user = await response.json(); diff --git a/docs/pages/providers/okta.md b/docs/pages/providers/okta.md index 5b2a077e..5d719f28 100644 --- a/docs/pages/providers/okta.md +++ b/docs/pages/providers/okta.md @@ -4,71 +4,97 @@ title: "Okta" # Okta -Implements OpenID Connect. +OAuth 2.0 provider for Okta. -For usage, see [OAuth 2.0 provider with PKCE](/guides/oauth2-pkce). +Also see the [OAuth 2.0](/guides/oauth2) guide. -**Note:** This provider implements a subset of Okta's full OAuth2 implementation. Specifically for applications of the "Web Application" type when using the OIDC sign-in method. +## Initialization -It is also recommended to toggle "Require PKCE as additional verification" under client credentials after creating your application in the Okta admin dashboard, as the implementation forces you to use PKCE anyway. - -If you want to utilize the refresh functionality of Arctic you need to toggle the "Refresh Token" option for "Client acting on behalf of a user". You can find this option under "Grant type" in the general settings for the application. +The `domain` parameter should not include the protocol or path. The `authorizationServerId` parameter is optional. ```ts import { Okta } from "arctic"; -const oktaDomain = "https://example.okta.com"; +const domain = "auth.example.com"; -const okta = new Okta(oktaDomain, clientId, clientSecret, redirectURI, { - // optional - authServerId -}); +const okta = new Okta(domain, null, clientId, clientSecret, redirectURI); +const okta = new Okta(domain, authorizationServerId, clientId, clientSecret, redirectURI); ``` -```ts -const url: URL = await okta.createAuthorizationURL(state, codeVerifier, { - // optional - scopes // "openid" always included -}); +## Create authorization URL -const tokens: OktaTokens = await okta.validateAuthorizationCode(code, codeVerifier); +```ts +import { generateState, generateCodeVerifier } from "arctic"; -const tokens: OktaTokens = await okta.refreshAccessToken(refreshToken, { - // optional - scopes -}); +const state = generateState(); +const codeVerifier = generateCodeVerifier(); +const scopes = ["openid", "profile"]; +const url = okta.createAuthorizationURL(state, codeVerifier, scopes); ``` -## Get user profile +## Validate authorization code -Add the `profile` scope for basic information. Optionally add the `email` scope to get user email. See [Scopes](https://developer.okta.com/docs/reference/api/oidc/#scopes) for available scopes. +`validateAuthorizationCode()` will either return an [`OAuth2Tokens`](/reference/main/OAuth2Tokens), or throw one of [`OAuth2RequestError`](/reference/main/OAuth2RequestError), [`ArcticFetchError`](/reference/main/ArcticFetchError), or a standard `Error` (parse errors). Actual values returned by Okta depends on your configuration. ```ts -const url = await okta.createAuthorizationURL(state, codeVerifier, { - scopes: ["profile", "email"] -}); +import { OAuth2RequestError, ArcticFetchError } from "arctic"; + +try { + const tokens = await okta.validateAuthorizationCode(code, codeVerifier); + const accessToken = tokens.accessToken(); + const accessTokenExpiresAt = tokens.accessTokenExpiresAt(); + const refreshToken = tokens.refreshToken(); +} catch (e) { + if (e instanceof OAuth2RequestError) { + // Invalid authorization code, credentials, or redirect URI + const code = e.code; + // ... + } + if (e instanceof ArcticFetchError) { + // Failed to call `fetch()` + const cause = e.cause; + // ... + } + // Parse error +} ``` -Parse the ID token or use the [`userinfo` endpoint](https://developer.okta.com/docs/reference/api/oidc/#userinfo). See [ID token](https://developer.okta.com/docs/reference/api/oidc/#id-token). +## Refresh access tokens + +Use `refreshAccessToken()` to get a new access token using a refresh token. Okta requires you to pass scopes when refreshing tokens. This method also returns `OAuth2Tokens` and throws the same errors as `validateAuthorizationCode()` ```ts -const tokens = await okta.validateAuthorizationCode(code, codeVerifier); -const response = await fetch(oktaDomain + "/oauth2/v1/userinfo", { - headers: { - Authorization: `Bearer ${tokens.accessToken}` +import { OAuth2RequestError, ArcticFetchError } from "arctic"; + +try { + const tokens = await okta.refreshAccessToken(accessToken, scopes); + const accessToken = tokens.accessToken(); + const accessTokenExpiresAt = tokens.accessTokenExpiresAt(); +} catch (e) { + if (e instanceof OAuth2RequestError) { + // Invalid authorization code, credentials, or redirect URI } -}); -const user = await response.json(); + if (e instanceof ArcticFetchError) { + // Failed to call `fetch()` + } + // Parse error +} ``` -## Custom auhtorization server +## Revoke tokens -If you are using a [custom authorization server](https://developer.okta.com/docs/concepts/auth-servers/) pass the ID of it to the constructor options. +Use `revokeToken()` to revoke a token. This can throw the same errors as `validateAuthorizationCode()`. ```ts -import { Okta } from "arctic"; - -const okta = new Okta(oktaDomain, clientId, clientSecret, redirectURI, { - authServerId -}); +try { + await okta.revokeToken(token); +} catch (e) { + if (e instanceof OAuth2RequestError) { + // Invalid authorization code, credentials, or redirect URI + } + if (e instanceof ArcticFetchError) { + // Failed to call `fetch()` + } + // Parse error +} ``` diff --git a/docs/pages/providers/osu.md b/docs/pages/providers/osu.md index 288981e1..ffd72be2 100644 --- a/docs/pages/providers/osu.md +++ b/docs/pages/providers/osu.md @@ -4,41 +4,86 @@ title: "osu!" # osu! -For usage, see [OAuth 2.0 provider](/guides/oauth2). +OAuth 2.0 provider for osu! + +Also see the [OAuth 2.0](/guides/oauth2) guide. + +## Initialization ```ts import { Osu } from "arctic"; -const osu = new Osu(clientId, clientSecret, { - // optional - redirectURI // required when multiple redirect URIs are defined -}); +const osu = new Osu(clientId, clientSecret, redirectURI); ``` +## Create authorization URL + ```ts -const url: URL = await osu.createAuthorizationURL(state, { - // optional - scopes -}); -const tokens: OsuTokens = await osu.validateAuthorizationCode(code); -const tokens: OsuTokens = await osu.refreshAccessToken(refreshToken); +import { generateState } from "arctic"; + +const state = generateState(); +const scopes = ["public", "friends.read"]; +const url = osu.createAuthorizationURL(state, scopes); ``` -## Get user profile +## Validate authorization code -Use the [`/me` endpoint](https://osu.ppy.sh/docs/index.html#get-own-data). +`validateAuthorizationCode()` will either return an [`OAuth2Tokens`](/reference/main/OAuth2Tokens), or throw one of [`OAuth2RequestError`](/reference/main/OAuth2RequestError), [`ArcticFetchError`](/reference/main/ArcticFetchError), or a standard `Error` (parse errors). osu! returns an access token, the access token expiration, and a refresh token. ```ts -const url = await osu.createAuthorizationURL(state, { - scopes: ["identify"] // implicitly granted by osu! -}); +import { OAuth2RequestError, ArcticFetchError } from "arctic"; + +try { + const tokens = await osu.validateAuthorizationCode(code); + const accessToken = tokens.accessToken(); + const accessTokenExpiresAt = tokens.accessTokenExpiresAt(); + const refreshToken = tokens.refreshToken(); +} catch (e) { + if (e instanceof OAuth2RequestError) { + // Invalid authorization code, credentials, or redirect URI + const code = e.code; + // ... + } + if (e instanceof ArcticFetchError) { + // Failed to call `fetch()` + const cause = e.cause; + // ... + } + // Parse error +} ``` +## Refresh access tokens + +Use `refreshAccessToken()` to get a new access token using a refresh token. osu! returns the same values as during the authorization code validation. This method also returns `OAuth2Tokens` and throws the same errors as `validateAuthorizationCode()` + +```ts +import { OAuth2RequestError, ArcticFetchError } from "arctic"; + +try { + const tokens = await osu.refreshAccessToken(accessToken); + const accessToken = tokens.accessToken(); + const accessTokenExpiresAt = tokens.accessTokenExpiresAt(); + const refreshToken = tokens.refreshToken(); +} catch (e) { + if (e instanceof OAuth2RequestError) { + // Invalid authorization code, credentials, or redirect URI + } + if (e instanceof ArcticFetchError) { + // Failed to call `fetch()` + } + // Parse error +} +``` + +## Get user profile + +Use the [`/me` endpoint](https://osu.ppy.sh/docs/index.html#get-own-data). + ```ts -const tokens = await osu.validateAuthorizationCode(code); const response = await fetch("https://osu.ppy.sh/api/v2/me", { headers: { - Authorization: `Bearer ${tokens.accessToken}` + Authorization: `Bearer ${accessToken}` } }); const user = await response.json(); diff --git a/docs/pages/providers/patreon.md b/docs/pages/providers/patreon.md index 988b0939..fe8cba1d 100644 --- a/docs/pages/providers/patreon.md +++ b/docs/pages/providers/patreon.md @@ -4,7 +4,11 @@ title: "Patreon" # Patreon -For usage, see [OAuth 2.0 provider](/guides/oauth2). +OAuth 2.0 provider for Patreon. + +Also see the [OAuth 2.0](/guides/oauth2) guide. + +## Initialization ```ts import { Patreon } from "arctic"; @@ -12,13 +16,64 @@ import { Patreon } from "arctic"; const patreon = new Patreon(clientId, clientSecret, redirectURI); ``` +## Create authorization URL + ```ts -const url: URL = await patreon.createAuthorizationURL(state, { - // optional - scopes -}); -const tokens: PatreonTokens = await patreon.validateAuthorizationCode(code); -const tokens: PatreonTokens = await patreon.refreshAccessToken(refreshToken); +import { generateState } from "arctic"; + +const state = generateState(); +const scopes = ["identify", "identity[email]"]; +const url = patreon.createAuthorizationURL(state, scopes); +``` + +## Validate authorization code + +`validateAuthorizationCode()` will either return an [`OAuth2Tokens`](/reference/main/OAuth2Tokens), or throw one of [`OAuth2RequestError`](/reference/main/OAuth2RequestError), [`ArcticFetchError`](/reference/main/ArcticFetchError), or a standard `Error` (parse errors). Patreon returns an access token, the access token expiration, and a refresh token. + +```ts +import { OAuth2RequestError, ArcticFetchError } from "arctic"; + +try { + const tokens = await patreon.validateAuthorizationCode(code); + const accessToken = tokens.accessToken(); + const accessTokenExpiresAt = tokens.accessTokenExpiresAt(); + const refreshToken = tokens.refreshToken(); +} catch (e) { + if (e instanceof OAuth2RequestError) { + // Invalid authorization code, credentials, or redirect URI + const code = e.code; + // ... + } + if (e instanceof ArcticFetchError) { + // Failed to call `fetch()` + const cause = e.cause; + // ... + } + // Parse error +} +``` + +## Refresh access tokens + +Use `refreshAccessToken()` to get a new access token using a refresh token. Patreon returns the same values as during the authorization code validation. This method also returns `OAuth2Tokens` and throws the same errors as `validateAuthorizationCode()` + +```ts +import { OAuth2RequestError, ArcticFetchError } from "arctic"; + +try { + const tokens = await patreon.refreshAccessToken(accessToken); + const accessToken = tokens.accessToken(); + const accessTokenExpiresAt = tokens.accessTokenExpiresAt(); + const refreshToken = tokens.refreshToken(); +} catch (e) { + if (e instanceof OAuth2RequestError) { + // Invalid authorization code, credentials, or redirect URI + } + if (e instanceof ArcticFetchError) { + // Failed to call `fetch()` + } + // Parse error +} ``` ## Get user profile @@ -26,16 +81,14 @@ const tokens: PatreonTokens = await patreon.refreshAccessToken(refreshToken); Add the `identity` scope and use the [`/api/oauth2/v2/identity` endpoint](https://docs.patreon.com/#get-api-oauth2-v2-identity). Optionally add the `identity[email]` scope to get user email. ```ts -const url = await patreon.createAuthorizationURL(state, { - scopes: ["identify", "identity[email]"] -}); +const scopes = ["identify", "identity[email]"]; +const url = patreon.createAuthorizationURL(state, scopes); ``` ```ts -const tokens = await patreon.validateAuthorizationCode(code); const response = await fetch("https://www.patreon.com/api/oauth2/v2/identity", { headers: { - Authorization: `Bearer ${tokens.accessToken}` + Authorization: `Bearer ${accessToken}` } }); const user = await response.json(); diff --git a/docs/pages/providers/reddit.md b/docs/pages/providers/reddit.md index b6b652f6..92265b2f 100644 --- a/docs/pages/providers/reddit.md +++ b/docs/pages/providers/reddit.md @@ -4,42 +4,100 @@ title: "Reddit" # Reddit -For usage, see [OAuth 2.0 provider](/guides/oauth2). +OAuth 2.0 provider for Reddit. + +Also see the [OAuth 2.0](/guides/oauth2) guide. + +## Initialization ```ts import { Reddit } from "arctic"; -const reddit = new Reddit(clientId, clientSecret, redirectURI); +const patreon = new Reddit(clientId, clientSecret, redirectURI); ``` +## Create authorization URL + ```ts -const url: URL = await reddit.createAuthorizationURL(state, { - // optional - scopes -}); -const tokens: RedditTokens = await reddit.validateAuthorizationCode(code); -const tokens: RedditTokens = await reddit.refreshAccessToken(refreshToken); +import { generateState } from "arctic"; + +const state = generateState(); +const scopes = ["edit", "read"]; +const url = reddit.createAuthorizationURL(state, scopes); ``` -## Get user profile +## Validate authorization code -Use the `/me` endpoint. +`validateAuthorizationCode()` will either return an [`OAuth2Tokens`](/reference/main/OAuth2Tokens), or throw one of [`OAuth2RequestError`](/reference/main/OAuth2RequestError), [`ArcticFetchError`](/reference/main/ArcticFetchError), or a standard `Error` (parse errors). Reddit returns an access token and its expiration. ```ts -const tokens = await reddit.validateAuthorizationCode(code); -const response = await fetch("https://oauth.reddit.com/api/v1/me", { - headers: { - Authorization: `Bearer ${tokens.accessToken}` +import { OAuth2RequestError, ArcticFetchError } from "arctic"; + +try { + const tokens = await reddit.validateAuthorizationCode(code); + const accessToken = tokens.accessToken(); + const accessTokenExpiresAt = tokens.accessTokenExpiresAt(); +} catch (e) { + if (e instanceof OAuth2RequestError) { + // Invalid authorization code, credentials, or redirect URI + const code = e.code; + // ... } -}); -const user = await response.json(); + if (e instanceof ArcticFetchError) { + // Failed to call `fetch()` + const cause = e.cause; + // ... + } + // Parse error +} ``` -## Get refresh token +## Refresh access tokens -Set `duration` param to `permanent`. +Set the `duration` parameter to `permanent` to get refresh tokens. ```ts -const url = await dropbox.createAuthorizationURL(); +const url = reddit.createAuthorizationURL(state, scopes); url.searchParams.set("duration", "permanent"); ``` + +```ts +const tokens = await reddit.validateAuthorizationCode(code); +const accessToken = tokens.accessToken(); +const accessTokenExpiresAt = tokens.accessTokenExpiresAt(); +const refreshToken = tokens.refreshToken(); +``` + +Use `refreshAccessToken()` to get a new access token using a refresh token. Reddit returns the same values as during the authorization code validation. This method also returns `OAuth2Tokens` and throws the same errors as `validateAuthorizationCode()` + +```ts +import { OAuth2RequestError, ArcticFetchError } from "arctic"; + +try { + const tokens = await reddit.refreshAccessToken(accessToken); + const accessToken = tokens.accessToken(); + const accessTokenExpiresAt = tokens.accessTokenExpiresAt(); + const refreshToken = tokens.refreshToken(); +} catch (e) { + if (e instanceof OAuth2RequestError) { + // Invalid authorization code, credentials, or redirect URI + } + if (e instanceof ArcticFetchError) { + // Failed to call `fetch()` + } + // Parse error +} +``` + +## Get user profile + +Use the `/me` endpoint. + +```ts +const response = await fetch("https://oauth.reddit.com/api/v1/me", { + headers: { + Authorization: `Bearer ${accessToken}` + } +}); +const user = await response.json(); +``` diff --git a/docs/pages/providers/roblox.md b/docs/pages/providers/roblox.md index fc323575..5be30016 100644 --- a/docs/pages/providers/roblox.md +++ b/docs/pages/providers/roblox.md @@ -4,9 +4,11 @@ title: "Roblox" # Roblox -Implements OpenID Connect. +OAuth 2.0 provider for Roblox. -For usage, see [OAuth 2.0 provider with PKCE](/guides/oauth2-pkce). +Also see the [OAuth 2.0 with PKCE](/guides/oauth2-pkce) guide. + +## Initialization ```ts import { Roblox } from "arctic"; @@ -14,33 +16,115 @@ import { Roblox } from "arctic"; const roblox = new Roblox(clientId, clientSecret, redirectURI); ``` +## Create authorization URL + ```ts -const url: URL = await roblox.createAuthorizationURL(state, codeVerifier, { - // optional - scopes // "openid" always included -}); -const tokens: RobloxTokens = await roblox.validateAuthorizationCode(code, codeVerifier); -const tokens: RobloxTokens = await roblox.refreshAccessToken(refreshToken); +import { generateState, generateCodeVerifier } from "arctic"; + +const state = generateState(); +const codeVerifier = generateCodeVerifier(); +const scopes = ["openid", "profile"]; +const url = roblox.createAuthorizationURL(state, codeVerifier, scopes); ``` -## Get user profile +## Validate authorization code -Add the `profile` scope. +`validateAuthorizationCode()` will either return an [`OAuth2Tokens`](/reference/main/OAuth2Tokens), or throw one of [`OAuth2RequestError`](/reference/main/OAuth2RequestError), [`ArcticFetchError`](/reference/main/ArcticFetchError), or a standard `Error` (parse errors). Roblox returns an access token, the access token expiration, and a refresh token. ```ts -const url = await roblox.createAuthorizationURL(state, codeVerifier, { - scopes: ["profile"] -}); +import { OAuth2RequestError, ArcticFetchError } from "arctic"; + +try { + const tokens = await roblox.validateAuthorizationCode(code, codeVerifier); + const accessToken = tokens.accessToken(); + const accessTokenExpiresAt = tokens.accessTokenExpiresAt(); + const refreshToken = tokens.refreshToken(); +} catch (e) { + if (e instanceof OAuth2RequestError) { + // Invalid authorization code, credentials, or redirect URI + const code = e.code; + // ... + } + if (e instanceof ArcticFetchError) { + // Failed to call `fetch()` + const cause = e.cause; + // ... + } + // Parse error +} +``` + +## Refresh access tokens + +Use `refreshAccessToken()` to get a new access token using a refresh token. Roblox returns the same values as during the authorization code validation. This method also returns `OAuth2Tokens` and throws the same errors as `validateAuthorizationCode()` + +```ts +import { OAuth2RequestError, ArcticFetchError } from "arctic"; + +try { + const tokens = await roblox.refreshAccessToken(accessToken); + const accessToken = tokens.accessToken(); + const accessTokenExpiresAt = tokens.accessTokenExpiresAt(); +} catch (e) { + if (e instanceof OAuth2RequestError) { + // Invalid authorization code, credentials, or redirect URI + } + if (e instanceof ArcticFetchError) { + // Failed to call `fetch()` + } + // Parse error +} ``` -Parse the ID token or use the [`userinfo` endpoint](https://create.roblox.com/docs/cloud/reference/oauth2#get-v1userinfo). +## OpenID Connect + +Use OpenID Connect with the `openid` scope to get the user's profile with an ID token or the [`userinfo` endpoint](https://create.roblox.com/docs/cloud/reference/oauth2#get-v1userinfo). Arctic provides [`decodeIdToken()`](/reference/main/decodeIdToken) for decoding the token's payload. + +```ts +const scopes = ["openid"]; +const url = roblox.createAuthorizationURL(state, codeVerifier, scopes); +``` ```ts +import { decodeIdToken } from "arctic"; + const tokens = await roblox.validateAuthorizationCode(code, codeVerifier); +const idToken = tokens.idToken(); +const claims = decodeIdToken(idToken); +``` + +```ts const response = await fetch("https://apis.roblox.com/oauth/v1/userinfo", { headers: { - Authorization: `Bearer ${tokens.accessToken}` + Authorization: `Bearer ${accessToken}` } }); const user = await response.json(); ``` + +### Get user profile + +Make sure to add the `profile` scope to get the user profile and the `email` scope to get the user email. + +```ts +const scopes = ["openid", "profile", "email"]; +const url = roblox.createAuthorizationURL(state, codeVerifier, scopes); +``` + +## Revoke tokens + +Pass a token to `revokeToken()` to revoke all tokens associated with the authorization. This can throw the same errors as `validateAuthorizationCode()`. + +```ts +try { + await cognito.revokeToken(refreshToken); +} catch (e) { + if (e instanceof OAuth2RequestError) { + // Invalid authorization code, credentials, or redirect URI + } + if (e instanceof ArcticFetchError) { + // Failed to call `fetch()` + } + // Parse error +} +``` diff --git a/docs/pages/providers/salesforce.md b/docs/pages/providers/salesforce.md index 6e78f43b..9eb65186 100644 --- a/docs/pages/providers/salesforce.md +++ b/docs/pages/providers/salesforce.md @@ -4,43 +4,140 @@ title: "Salesforce" # Salesforce -Implements OpenID Connect. +OAuth 2.0 provider for Salesforce. -For usage, see [OAuth 2.0 provider with PKCE](/guides/oauth2-pkce). +Also see the [OAuth 2.0 with PKCE](/guides/oauth2-pkce) guide. + +## Initialization + +The `domain` parameter should not include paths or protocol. ```ts import { Salesforce } from "arctic"; -const salesforce = new Salesforce(clientId, clientSecret, redirectURI); +const domain = "login.salesforce.com"; +const salesforce = new Salesforce(domain, clientId, clientSecret, redirectURI); ``` +## Create authorization URL + ```ts -const url: URL = await salesforce.createAuthorizationURL(state, { - // optional - scopes -}); -const tokens: SalesforceTokens = await salesforce.validateAuthorizationCode(code); -const tokens: SalesforceTokens = await salesforce.refreshAccessToken(refreshToken); +import { generateState, generateCodeVerifier } from "arctic"; + +const state = generateState(); +const codeVerifier = generateCodeVerifier(); +const scopes = ["openid", "profile"]; +const url = salesforce.createAuthorizationURL(state, codeVerifier, scopes); ``` -## Get user profile +## Validate authorization code -Add the `profile` scope. Optionally add the `email` scope to get user email. +`validateAuthorizationCode()` will either return an [`OAuth2Tokens`](/reference/main/OAuth2Tokens), or throw one of [`OAuth2RequestError`](/reference/main/OAuth2RequestError), [`ArcticFetchError`](/reference/main/ArcticFetchError), or a standard `Error` (parse errors). Salesforce only returns an access token. ```ts -const url = await salesforce.createAuthorizationURL(state, { - scopes: ["profile", "email"] -}); +import { OAuth2RequestError, ArcticFetchError } from "arctic"; + +try { + const tokens = await salesforce.validateAuthorizationCode(code, codeVerifier); + const accessToken = tokens.accessToken(); +} catch (e) { + if (e instanceof OAuth2RequestError) { + // Invalid authorization code, credentials, or redirect URI + const code = e.code; + // ... + } + if (e instanceof ArcticFetchError) { + // Failed to call `fetch()` + const cause = e.cause; + // ... + } + // Parse error +} +``` + +## Refresh access tokens + +Add the `refresh_token` scope to get refresh tokens. + +```ts +const scopes = ["refresh_token"]; +const url = dropbox.createAuthorizationURL(state, codeVerifier, scopes); +``` + +```ts +const tokens = await dropbox.validateAuthorizationCode(code); +const accessToken = tokens.accessToken(); +const refreshToken = tokens.refreshToken(); +``` + +Use `refreshAccessToken()` to get a new access token using a refresh token. Salesforce only returns an access token. This method also returns `OAuth2Tokens` and throws the same errors as `validateAuthorizationCode()` + +```ts +import { OAuth2RequestError, ArcticFetchError } from "arctic"; + +try { + const tokens = await salesforce.refreshAccessToken(accessToken); + const accessToken = tokens.accessToken(); +} catch (e) { + if (e instanceof OAuth2RequestError) { + // Invalid authorization code, credentials, or redirect URI + } + if (e instanceof ArcticFetchError) { + // Failed to call `fetch()` + } + // Parse error +} ``` -Use the [`/userinfo` endpoint](https://help.salesforce.com/s/articleView?id=sf.remoteaccess_using_userinfo_endpoint.htm&type=5). +## OpenID Connect + +Use OpenID Connect with the `openid` scope to get the user's profile with an ID token or the `userinfo` endpoint. Arctic provides [`decodeIdToken()`](/reference/main/decodeIdToken) for decoding the token's payload. + +```ts +const scopes = ["openid"]; +const url = roblox.createAuthorizationURL(state, codeVerifier, scopes); +``` + +```ts +import { decodeIdToken } from "arctic"; + +const tokens = await roblox.validateAuthorizationCode(code, codeVerifier); +const idToken = tokens.idToken(); +const claims = decodeIdToken(idToken); +``` ```ts -const tokens = await salesforce.validateAuthorizationCode(code); const response = await fetch("https://login.salesforce.com/services/oauth2/userinfo", { headers: { - Authorization: `Bearer ${tokens.accessToken}` + Authorization: `Bearer ${accessToken}` } }); const user = await response.json(); ``` + +### Get user profile + +Make sure to add the `profile` scope to get the user profile and the `email` scope to get the user email. + +```ts +const scopes = ["openid", "profile", "email"]; +const url = roblox.createAuthorizationURL(state, codeVerifier, scopes); +``` + +## Revoke tokens + +Revoke tokens with `revokeToken()`. This can throw the same errors as `validateAuthorizationCode()`. + +```ts +try { + await salesforce.revokeToken(token); +} catch (e) { + if (e instanceof OAuth2RequestError) { + // Invalid authorization code, credentials, or redirect URI + } + if (e instanceof ArcticFetchError) { + // Failed to call `fetch()` + } + // Parse error +} +``` diff --git a/docs/pages/providers/shikimori.md b/docs/pages/providers/shikimori.md index cb22a0d3..5b61abed 100644 --- a/docs/pages/providers/shikimori.md +++ b/docs/pages/providers/shikimori.md @@ -4,7 +4,11 @@ title: "Shikimori" # Shikimori -For usage, see [OAuth 2.0 provider](/guides/oauth2). +OAuth 2.0 provider for Shikimori. + +Also see the [OAuth 2.0](/guides/oauth2) guide. + +## Initialization ```ts import { Shikimori } from "arctic"; @@ -12,10 +16,72 @@ import { Shikimori } from "arctic"; const shikimori = new Shikimori(clientId, clientSecret, redirectURI); ``` +## Create authorization URL + +```ts +import { generateState } from "arctic"; + +const state = generateState(); +const url = shikimori.createAuthorizationURL(state); +``` + +## Validate authorization code + +`validateAuthorizationCode()` will either return an [`OAuth2Tokens`](/reference/main/OAuth2Tokens), or throw one of [`OAuth2RequestError`](/reference/main/OAuth2RequestError), [`ArcticFetchError`](/reference/main/ArcticFetchError), or a standard `Error` (parse errors). Shikimori returns an access token, the access token expiration, and a refresh token. + ```ts -import type { ShikimoriTokens } from "arctic"; +import { OAuth2RequestError, ArcticFetchError } from "arctic"; -const url: URL = await shikimori.createAuthorizationURL(state); -const tokens: ShikimoriTokens = await shikimori.validateAuthorizationCode(code); -const refreshedTokens: ShikimoriTokens = await shikimori.refreshAccessToken(refreshToken); +try { + const tokens = await shikimori.validateAuthorizationCode(code); + const accessToken = tokens.accessToken(); + const accessTokenExpiresAt = tokens.accessTokenExpiresAt(); + const refreshToken = tokens.refreshToken(); +} catch (e) { + if (e instanceof OAuth2RequestError) { + // Invalid authorization code, credentials, or redirect URI + const code = e.code; + // ... + } + if (e instanceof ArcticFetchError) { + // Failed to call `fetch()` + const cause = e.cause; + // ... + } + // Parse error +} +``` + +## Refresh access tokens + +Use `refreshAccessToken()` to get a new access token using a refresh token. Shikimori returns the same values as during the authorization code validation. This method also returns `OAuth2Tokens` and throws the same errors as `validateAuthorizationCode()` + +```ts +import { OAuth2RequestError, ArcticFetchError } from "arctic"; + +try { + const tokens = await shikimori.refreshAccessToken(accessToken); + const accessToken = tokens.accessToken(); + const accessTokenExpiresAt = tokens.accessTokenExpiresAt(); + const refreshToken = tokens.refreshToken(); +} catch (e) { + if (e instanceof OAuth2RequestError) { + // Invalid authorization code, credentials, or redirect URI + } + if (e instanceof ArcticFetchError) { + // Failed to call `fetch()` + } + // Parse error +} +``` + +## Get user profile + +```ts +const response = await fetch("https://shikimori.one/api/users/whoami", { + headers: { + Authorization: `Bearer ${accessToken}` + } +}); +const user = await response.json(); ``` diff --git a/docs/pages/providers/slack.md b/docs/pages/providers/slack.md index 02a68f36..020af32d 100644 --- a/docs/pages/providers/slack.md +++ b/docs/pages/providers/slack.md @@ -2,44 +2,83 @@ title: "Slack" --- -# Slack +# Slack (OpenID) -Implements OpenID Connect. +OAuth 2.0 provider for Slack (OpenID Connect). -For usage, see [OAuth 2.0 provider](/guides/oauth2). +Also see the [OAuth 2.0](/guides/oauth2) guide. + +## Initialization + +The redirect URI is optional. ```ts -import { Slack } from "arctic"; +import { SlackOIDC } from "arctic"; -const slack = new Slack(clientId, clientSecret, redirectURI); +const slack = new SlackOIDC(clientId, clientSecret, null); +const slack = new SlackOIDC(clientId, clientSecret, redirectURI); ``` +## Create authorization URL + +**The `openid` scope is required.** + ```ts -const url: URL = await slack.createAuthorizationURL(state, { - // optional - scopes // "openid" always included -}); -const tokens: SlackTokens = await slack.validateAuthorizationCode(code); +import { generateState } from "arctic"; + +const state = generateState(); +const scopes = ["openid", "profile"]; +const url = slack.createAuthorizationURL(state, scopes); ``` -## Get user profile +## Validate authorization code -Add the `profile` scope. Optionally add the `email` scope to get user email. +`validateAuthorizationCode()` will either return an [`OAuth2Tokens`](/reference/main/OAuth2Tokens), or throw one of [`OAuth2RequestError`](/reference/main/OAuth2RequestError), [`ArcticFetchError`](/reference/main/ArcticFetchError), or a standard `Error` (parse errors). Slack will return an access token (no expiration) and an ID token. ```ts -const url = await slack.createAuthorizationURL(state, { - scopes: ["profile", "email"] -}); +import { OAuth2RequestError, ArcticFetchError } from "arctic"; + +try { + const tokens = await slack.validateAuthorizationCode(code); + const accessToken = tokens.accessToken(); + const idToken = tokens.idToken(); +} catch (e) { + if (e instanceof OAuth2RequestError) { + // Invalid authorization code, credentials, or redirect URI + const code = e.code; + // ... + } + if (e instanceof ArcticFetchError) { + // Failed to call `fetch()` + const cause = e.cause; + // ... + } + // Parse error +} ``` -Parse the ID token or use the `userinfo` endpoint. See [example ID token claims](https://api.slack.com/authentication/sign-in-with-slack#response). +## Get user profile + +Decode the ID token or the `userinfo` endpoint to get the user profile. Arctic provides [`decodeIdToken()`](/reference/main/decodeIdToken) for decoding the token's payload. + +```ts +import { decodeIdToken } from "arctic"; + +const claims = decodeIdToken(idToken); +``` ```ts -const tokens = await slack.validateAuthorizationCode(code); -const response = await fetch("https://slack.com/api/openid.connect.userInfo", { +const response = await fetch("https://openidconnect.googleapis.com/v1/userinfo", { headers: { - Authorization: `Bearer ${tokens.accessToken}` + Authorization: `Bearer ${accessToken}` } }); const user = await response.json(); ``` + +Make sure to add the `profile` scope to get the user profile and the `email` scope to get the user email. + +```ts +const scopes = ["openid", "profile", "email"]; +const url = slack.createAuthorizationURL(state, codeVerifier, scopes); +``` diff --git a/docs/pages/providers/soundcloud.md b/docs/pages/providers/soundcloud.md new file mode 100644 index 00000000..2a5ea9a0 --- /dev/null +++ b/docs/pages/providers/soundcloud.md @@ -0,0 +1,78 @@ +--- +title: "SoundCloud" +--- + +# SoundCloud + +OAuth 2.0 provider for SoundCloud. + +Also see the [OAuth 2.0](/guides/oauth2) guide. + +## Initialization + +```ts +import { SoundCloud } from "arctic"; + +const soundcloud = new SoundCloud(clientId, clientSecret, redirectURI); +``` + +## Create authorization URL + +```ts +import { generateState, createS256CodeChallenge } from "arctic"; + +const state = generateState(); +const codeVerifier = generateCodeVerifier(); + +const url = soundcloud.createAuthorizationURL(state, codeVerifier); +``` + +## Validate authorization code + +`validateAuthorizationCode()` will either return an [`OAuth2Tokens`](/reference/main/OAuth2Tokens), or throw one of [`OAuth2RequestError`](/reference/main/OAuth2RequestError), [`ArcticFetchError`](/reference/main/ArcticFetchError), or a standard `Error` (parse errors). SoundCloud returns an access token, the access token expiration, and a refresh token. + +```ts +import { OAuth2RequestError, ArcticFetchError } from "arctic"; + +try { + const tokens = await soundcloud.validateAuthorizationCode(code, s256Challenge); + const accessToken = tokens.accessToken(); + const accessTokenExpiresAt = tokens.accessTokenExpiresAt(); + const refreshToken = tokens.refreshToken(); +} catch (e) { + if (e instanceof OAuth2RequestError) { + // Invalid authorization code, credentials, or redirect URI + const code = e.code; + // ... + } + if (e instanceof ArcticFetchError) { + // Failed to call `fetch()` + const cause = e.cause; + // ... + } + // Parse error +} +``` + +## Refresh access tokens + +Use `refreshAccessToken()` to get a new access token using a refresh token. SoundCloud returns the same values as during the authorization code validation. This method also returns `OAuth2Tokens` and throws the same errors as `validateAuthorizationCode()` + +```ts +import { OAuth2RequestError, ArcticFetchError } from "arctic"; + +try { + const tokens = await soundcloud.refreshAccessToken(accessToken); + const accessToken = tokens.accessToken(); + const accessTokenExpiresAt = tokens.accessTokenExpiresAt(); + const refreshToken = tokens.refreshToken(); +} catch (e) { + if (e instanceof OAuth2RequestError) { + // Invalid authorization code, credentials, or redirect URI + } + if (e instanceof ArcticFetchError) { + // Failed to call `fetch()` + } + // Parse error +} +``` diff --git a/docs/pages/providers/spotify.md b/docs/pages/providers/spotify.md index 34c9c64b..cd3bf35a 100644 --- a/docs/pages/providers/spotify.md +++ b/docs/pages/providers/spotify.md @@ -4,7 +4,11 @@ title: "Spotify" # Spotify -For usage, see [OAuth 2.0 provider](/guides/oauth2). +OAuth 2.0 provider for Spotify. + +Also see the [OAuth 2.0](/guides/oauth2) guide. + +## Initialization ```ts import { Spotify } from "arctic"; @@ -12,24 +16,74 @@ import { Spotify } from "arctic"; const spotify = new Spotify(clientId, clientSecret, redirectURI); ``` +## Create authorization URL + ```ts -const url: URL = await spotify.createAuthorizationURL(state, { - // optional - scopes -}); -const tokens: SpotifyTokens = await spotify.validateAuthorizationCode(code); -const tokens: SpotifyTokens = await spotify.refreshAccessToken(refreshToken); +import { generateState } from "arctic"; + +const state = generateState(); +const scopes = ["user-read-email", "user-read-private"]; +const url = spotify.createAuthorizationURL(state, scopes); +``` + +## Validate authorization code + +`validateAuthorizationCode()` will either return an [`OAuth2Tokens`](/reference/main/OAuth2Tokens), or throw one of [`OAuth2RequestError`](/reference/main/OAuth2RequestError), [`ArcticFetchError`](/reference/main/ArcticFetchError), or a standard `Error` (parse errors). Spotify returns an access token, the access token expiration, and a refresh token. + +```ts +import { OAuth2RequestError, ArcticFetchError } from "arctic"; + +try { + const tokens = await spotify.validateAuthorizationCode(code); + const accessToken = tokens.accessToken(); + const accessTokenExpiresAt = tokens.accessTokenExpiresAt(); + const refreshToken = tokens.refreshToken(); +} catch (e) { + if (e instanceof OAuth2RequestError) { + // Invalid authorization code, credentials, or redirect URI + const code = e.code; + // ... + } + if (e instanceof ArcticFetchError) { + // Failed to call `fetch()` + const cause = e.cause; + // ... + } + // Parse error +} +``` + +## Refresh access tokens + +Use `refreshAccessToken()` to get a new access token using a refresh token. Spotify returns the same values as during the authorization code validation. This method also returns `OAuth2Tokens` and throws the same errors as `validateAuthorizationCode()` + +```ts +import { OAuth2RequestError, ArcticFetchError } from "arctic"; + +try { + const tokens = await spotify.refreshAccessToken(accessToken); + const accessToken = tokens.accessToken(); + const accessTokenExpiresAt = tokens.accessTokenExpiresAt(); + const refreshToken = tokens.refreshToken(); +} catch (e) { + if (e instanceof OAuth2RequestError) { + // Invalid authorization code, credentials, or redirect URI + } + if (e instanceof ArcticFetchError) { + // Failed to call `fetch()` + } + // Parse error +} ``` ## Get user profile -Use the [`/users/me` endpoint](https://developer.spotify.com/documentation/web-api/reference/get-current-users-profile). +Use the [`/users/me` endpoint](https://developer.spotify.com/documentation/web-api/reference/get-current-users-profile). The `user-read-email` scope is required to get the user's email. ```ts -const tokens = await spotify.validateAuthorizationCode(code); const response = await fetch("https://api.spotify.com/v1/me", { headers: { - Authorization: `Bearer ${tokens.accessToken}` + Authorization: `Bearer ${accessToken}` } }); const user = await response.json(); diff --git a/docs/pages/providers/strava.md b/docs/pages/providers/strava.md index 75500111..3904dfd8 100644 --- a/docs/pages/providers/strava.md +++ b/docs/pages/providers/strava.md @@ -4,7 +4,11 @@ title: "Strava" # Strava -For usage, see [OAuth 2.0 provider](/guides/oauth2). +OAuth 2.0 provider for Strava. + +Also see the [OAuth 2.0](/guides/oauth2) guide. + +## Initialization ```ts import { Strava } from "arctic"; @@ -12,13 +16,64 @@ import { Strava } from "arctic"; const strava = new Strava(clientId, clientSecret, redirectURI); ``` +## Create authorization URL + ```ts -const url: URL = await strava.createAuthorizationURL(state, { - // optional - scopes -}); -const tokens: StravaTokens = await strava.validateAuthorizationCode(code); -const tokens: StravaTokens = await strava.refreshAccessToken(refreshToken); +import { generateState } from "arctic"; + +const state = generateState(); +const scopes = ["activity:write", "read"]; +const url = strava.createAuthorizationURL(state, scopes); +``` + +## Validate authorization code + +`validateAuthorizationCode()` will either return an [`OAuth2Tokens`](/reference/main/OAuth2Tokens), or throw one of [`OAuth2RequestError`](/reference/main/OAuth2RequestError), [`ArcticFetchError`](/reference/main/ArcticFetchError), or a standard `Error` (parse errors). Strava returns an access token, the access token expiration, and a refresh token. + +```ts +import { OAuth2RequestError, ArcticFetchError } from "arctic"; + +try { + const tokens = await strava.validateAuthorizationCode(code); + const accessToken = tokens.accessToken(); + const accessTokenExpiresAt = tokens.accessTokenExpiresAt(); + const refreshToken = tokens.refreshToken(); +} catch (e) { + if (e instanceof OAuth2RequestError) { + // Invalid authorization code, credentials, or redirect URI + const code = e.code; + // ... + } + if (e instanceof ArcticFetchError) { + // Failed to call `fetch()` + const cause = e.cause; + // ... + } + // Parse error +} +``` + +## Refresh access tokens + +Use `refreshAccessToken()` to get a new access token using a refresh token. Strava returns the same values as during the authorization code validation. This method also returns `OAuth2Tokens` and throws the same errors as `validateAuthorizationCode()` + +```ts +import { OAuth2RequestError, ArcticFetchError } from "arctic"; + +try { + const tokens = await strava.refreshAccessToken(accessToken); + const accessToken = tokens.accessToken(); + const accessTokenExpiresAt = tokens.accessTokenExpiresAt(); + const refreshToken = tokens.refreshToken(); +} catch (e) { + if (e instanceof OAuth2RequestError) { + // Invalid authorization code, credentials, or redirect URI + } + if (e instanceof ArcticFetchError) { + // Failed to call `fetch()` + } + // Parse error +} ``` ## Get user profile @@ -26,16 +81,14 @@ const tokens: StravaTokens = await strava.refreshAccessToken(refreshToken); Add the `read` scope and use the [`/athlete` endpoint](https://developers.strava.com/docs/reference/#api-Athletes-getLoggedInAthlete). Alternatively, use the `read_all` scope to get all private data. ```ts -const url = await strava.createAuthorizationURL(state, { - scopes: ["read"] -}); +const scopes = ["read"]; +const url = strava.createAuthorizationURL(state, scopes); ``` ```ts -const tokens = await strava.validateAuthorizationCode(code); const response = await fetch("https://www.strava.com/api/v3/athlete", { headers: { - Authorization: `Bearer ${tokens.accessToken}` + Authorization: `Bearer ${accessToken}` } }); const user = await response.json(); diff --git a/docs/pages/providers/tiltify.md b/docs/pages/providers/tiltify.md index 526a3812..a7b5f176 100644 --- a/docs/pages/providers/tiltify.md +++ b/docs/pages/providers/tiltify.md @@ -4,7 +4,11 @@ title: "Tiltify" # Tiltify -For usage, see [OAuth 2.0 provider](/guides/oauth2). +OAuth 2.0 provider for Tiltify. + +Also see the [OAuth 2.0](/guides/oauth2) guide. + +## Initialization ```ts import { Tiltify } from "arctic"; @@ -12,24 +16,74 @@ import { Tiltify } from "arctic"; const tiltify = new Tiltify(clientId, clientSecret, redirectURI); ``` +## Create authorization URL + ```ts -const url: URL = await tiltify.createAuthorizationURL(state, { - // optional - scopes -}); -const tokens: TiltifyTokens = await tiltify.validateAuthorizationCode(code); -const tokens: TiltifyTokens = await tiltify.refreshAccessToken(refreshToken); +import { generateState } from "arctic"; + +const state = generateState(); +const scopes = ["activity:write", "read"]; +const url = tiltify.createAuthorizationURL(state, scopes); +``` + +## Validate authorization code + +`validateAuthorizationCode()` will either return an [`OAuth2Tokens`](/reference/main/OAuth2Tokens), or throw one of [`OAuth2RequestError`](/reference/main/OAuth2RequestError), [`ArcticFetchError`](/reference/main/ArcticFetchError), or a standard `Error` (parse errors). Tiltify returns an access token, the access token expiration, and a refresh token. + +```ts +import { OAuth2RequestError, ArcticFetchError } from "arctic"; + +try { + const tokens = await tiltify.validateAuthorizationCode(code); + const accessToken = tokens.accessToken(); + const accessTokenExpiresAt = tokens.accessTokenExpiresAt(); + const refreshToken = tokens.refreshToken(); +} catch (e) { + if (e instanceof OAuth2RequestError) { + // Invalid authorization code, credentials, or redirect URI + const code = e.code; + // ... + } + if (e instanceof ArcticFetchError) { + // Failed to call `fetch()` + const cause = e.cause; + // ... + } + // Parse error +} +``` + +## Refresh access tokens + +Use `refreshAccessToken()` to get a new access token using a refresh token. Tiltify returns the same values as during the authorization code validation. This method also returns `OAuth2Tokens` and throws the same errors as `validateAuthorizationCode()` + +```ts +import { OAuth2RequestError, ArcticFetchError } from "arctic"; + +try { + const tokens = await tiltify.refreshAccessToken(accessToken); + const accessToken = tokens.accessToken(); + const accessTokenExpiresAt = tokens.accessTokenExpiresAt(); + const refreshToken = tokens.refreshToken(); +} catch (e) { + if (e instanceof OAuth2RequestError) { + // Invalid authorization code, credentials, or redirect URI + } + if (e instanceof ArcticFetchError) { + // Failed to call `fetch()` + } + // Parse error +} ``` -## Get current user +## Get user profile Use the [`/api/public/current-use` endpoint](https://developers.tiltify.com/api-reference/public#tag/user/operation/V5ApiWeb.Public.UserController.current_user) without passing any arguments. ```ts -const tokens = await twitch.validateAuthorizationCode(code); const response = await fetch("https://v5api.tiltify.com/api/public/current-user", { headers: { - Authorization: `Bearer ${tokens.accessToken}`, + Authorization: `Bearer ${accessToken}`, "Client-Id": clientId } }); diff --git a/docs/pages/providers/tumblr.md b/docs/pages/providers/tumblr.md index 09ca4c37..4cfc4262 100644 --- a/docs/pages/providers/tumblr.md +++ b/docs/pages/providers/tumblr.md @@ -4,17 +4,89 @@ title: "Tumblr" # Tumblr -For usage, see [OAuth 2.0 provider](/guides/oauth2). +OAuth 2.0 provider for Tumblr. + +Also see the [OAuth 2.0](/guides/oauth2) guide. + +## Initialization ```ts import { Tumblr } from "arctic"; -const tumblr = new Tumblr(clientId, clientSecret, redirectURI); +const patreon = new Tumblr(clientId, clientSecret, redirectURI); ``` +## Create authorization URL + ```ts -const url: URL = await tumblr.createAuthorizationURL(state); -const tokens: TumblrTokens = await tumblr.validateAuthorizationCode(code); +import { generateState } from "arctic"; + +const state = generateState(); +const scopes = ["basic", "write"]; +const url = tumblr.createAuthorizationURL(state, scopes); +``` + +## Validate authorization code + +`validateAuthorizationCode()` will either return an [`OAuth2Tokens`](/reference/main/OAuth2Tokens), or throw one of [`OAuth2RequestError`](/reference/main/OAuth2RequestError), [`ArcticFetchError`](/reference/main/ArcticFetchError), or a standard `Error` (parse errors). Tumblr returns an access token and its expiration. + +```ts +import { OAuth2RequestError, ArcticFetchError } from "arctic"; + +try { + const tokens = await tumblr.validateAuthorizationCode(code); + const accessToken = tokens.accessToken(); + const accessTokenExpiresAt = tokens.accessTokenExpiresAt(); +} catch (e) { + if (e instanceof OAuth2RequestError) { + // Invalid authorization code, credentials, or redirect URI + const code = e.code; + // ... + } + if (e instanceof ArcticFetchError) { + // Failed to call `fetch()` + const cause = e.cause; + // ... + } + // Parse error +} +``` + +## Refresh access tokens + +Add the `offline_access` scope to get refresh tokens. + +```ts +const scopes = ["offline_access"]; +const url = tumblr.createAuthorizationURL(state, scopes); +``` + +```ts +const tokens = await tumblr.validateAuthorizationCode(code); +const accessToken = tokens.accessToken(); +const accessTokenExpiresAt = tokens.accessTokenExpiresAt(); +const refreshToken = tokens.refreshToken(); +``` + +Use `refreshAccessToken()` to get a new access token using a refresh token. Tumblr returns the same values as during the authorization code validation. This method also returns `OAuth2Tokens` and throws the same errors as `validateAuthorizationCode()` + +```ts +import { OAuth2RequestError, ArcticFetchError } from "arctic"; + +try { + const tokens = await tumblr.refreshAccessToken(accessToken); + const accessToken = tokens.accessToken(); + const accessTokenExpiresAt = tokens.accessTokenExpiresAt(); + const refreshToken = tokens.refreshToken(); +} catch (e) { + if (e instanceof OAuth2RequestError) { + // Invalid authorization code, credentials, or redirect URI + } + if (e instanceof ArcticFetchError) { + // Failed to call `fetch()` + } + // Parse error +} ``` ## Get user profile @@ -22,10 +94,9 @@ const tokens: TumblrTokens = await tumblr.validateAuthorizationCode(code); Use the [`/user/info` endpoint](https://www.tumblr.com/docs/en/api/v2#userinfo--get-a-users-information). ```ts -const tokens = await tumblr.validateAuthorizationCode(code); const response = await fetch("https://api.tumblr.com/v2/user/info", { headers: { - Authorization: `Bearer ${tokens.accessToken}` + Authorization: `Bearer ${accessToken}` } }); const user = await response.json(); diff --git a/docs/pages/providers/twitch.md b/docs/pages/providers/twitch.md index 9fff8474..cf840a98 100644 --- a/docs/pages/providers/twitch.md +++ b/docs/pages/providers/twitch.md @@ -4,7 +4,11 @@ title: "Twitch" # Twitch -For usage, see [OAuth 2.0 provider](/guides/oauth2). +OAuth 2.0 provider for Twitch. + +Also see the [OAuth 2.0](/guides/oauth2) guide. + +## Initialization ```ts import { Twitch } from "arctic"; @@ -12,34 +16,77 @@ import { Twitch } from "arctic"; const twitch = new Twitch(clientId, clientSecret, redirectURI); ``` +## Create authorization URL + ```ts -const url: URL = await twitch.createAuthorizationURL(state, { - // optional - scopes -}); -const tokens: TwitchTokens = await twitch.validateAuthorizationCode(code); -const tokens: TwitchTokens = await twitch.refreshAccessToken(refreshToken); +import { generateState } from "arctic"; + +const state = generateState(); +const scopes = ["activity:write", "read"]; +const url = twitch.createAuthorizationURL(state, scopes); +``` + +## Validate authorization code + +`validateAuthorizationCode()` will either return an [`OAuth2Tokens`](/reference/main/OAuth2Tokens), or throw one of [`OAuth2RequestError`](/reference/main/OAuth2RequestError), [`ArcticFetchError`](/reference/main/ArcticFetchError), or a standard `Error` (parse errors). Twitch returns an access token, the access token expiration, and a refresh token. + +```ts +import { OAuth2RequestError, ArcticFetchError } from "arctic"; + +try { + const tokens = await twitch.validateAuthorizationCode(code); + const accessToken = tokens.accessToken(); + const accessTokenExpiresAt = tokens.accessTokenExpiresAt(); + const refreshToken = tokens.refreshToken(); +} catch (e) { + if (e instanceof OAuth2RequestError) { + // Invalid authorization code, credentials, or redirect URI + const code = e.code; + // ... + } + if (e instanceof ArcticFetchError) { + // Failed to call `fetch()` + const cause = e.cause; + // ... + } + // Parse error +} +``` + +## Refresh access tokens + +Use `refreshAccessToken()` to get a new access token using a refresh token. Twitch returns the same values as during the authorization code validation. This method also returns `OAuth2Tokens` and throws the same errors as `validateAuthorizationCode()` + +```ts +import { OAuth2RequestError, ArcticFetchError } from "arctic"; + +try { + const tokens = await twitch.refreshAccessToken(accessToken); + const accessToken = tokens.accessToken(); + const accessTokenExpiresAt = tokens.accessTokenExpiresAt(); + const refreshToken = tokens.refreshToken(); +} catch (e) { + if (e instanceof OAuth2RequestError) { + // Invalid authorization code, credentials, or redirect URI + } + if (e instanceof ArcticFetchError) { + // Failed to call `fetch()` + } + // Parse error +} ``` ## Get user profile -Use the [`/users` endpoint](https://dev.twitch.tv/docs/api/reference/#get-users) without passing any arguments. +Use the [`/users` endpoint](https://dev.twitch.tv/docs/api/reference/#get-users) without passing any arguments. The `user:read:email` scope is required to get the user's email from the endpoint. ```ts const tokens = await twitch.validateAuthorizationCode(code); const response = await fetch("https://api.twitch.tv/helix/users", { headers: { - Authorization: `Bearer ${tokens.accessToken}`, + Authorization: `Bearer ${accessToken}`, "Client-Id": clientId } }); const user = await response.json(); ``` - -Add the `user:read:email` scope to get the user's email from the API. - -```ts -const url = await twitch.createAuthorizationURL(state, { - scopes: ["user:read:email"] -}); -``` diff --git a/docs/pages/providers/twitter.md b/docs/pages/providers/twitter.md index de123783..765528cc 100644 --- a/docs/pages/providers/twitter.md +++ b/docs/pages/providers/twitter.md @@ -4,9 +4,11 @@ title: "Twitter" # Twitter -For Twitter API v2. +OAuth 2.0 provider for Twitter API v2. -For usage, see [OAuth 2.0 provider with PKCE](/guides/oauth2-pkce). +Also see the [OAuth 2.0 with PKCE](/guides/oauth2-pkce) guide. + +## Initialization ```ts import { Twitter } from "arctic"; @@ -14,13 +16,40 @@ import { Twitter } from "arctic"; const twitter = new Twitter(clientId, clientSecret, redirectURI); ``` +## Create authorization URL + ```ts -const url: URL = await twitter.createAuthorizationURL(state, codeVerifier, { - // optional - scopes -}); -const tokens: TwitterTokens = await twitter.validateAuthorizationCode(code, codeVerifier); -const tokens: TwitterTokens = await twitter.refreshAccessToken(refreshToken); +import { generateState } from "arctic"; + +const state = generateState(); +const scops = ["account_info.read", "files.content.read"]; +const url = twitter.createAuthorizationURL(state, scopes); +``` + +## Validate authorization code + +`validateAuthorizationCode()` will either return an [`OAuth2Tokens`](/reference/main/OAuth2Tokens), or throw one of [`OAuth2RequestError`](/reference/main/OAuth2RequestError), [`ArcticFetchError`](/reference/main/ArcticFetchError), or a standard `Error` (parse errors). Twitter returns an access token and its expiration. + +```ts +import { OAuth2RequestError, ArcticFetchError } from "arctic"; + +try { + const tokens = await twitter.validateAuthorizationCode(code, codeVerifier); + const accessToken = tokens.accessToken(); + const accessTokenExpiresAt = tokens.accessTokenExpiresAt(); +} catch (e) { + if (e instanceof OAuth2RequestError) { + // Invalid authorization code, credentials, or redirect URI + const code = e.code; + // ... + } + if (e instanceof ArcticFetchError) { + // Failed to call `fetch()` + const cause = e.cause; + // ... + } + // Parse error +} ``` ## Get user profile @@ -28,27 +57,70 @@ const tokens: TwitterTokens = await twitter.refreshAccessToken(refreshToken); Add the `users.read` and `tweet.read` scopes and use the [`/users/me` endpoint](https://developer.twitter.com/en/docs/twitter-api/users/lookup/api-reference/get-users-me). You cannot get user emails with the v2 API. ```ts -const url = await twitter.createAuthorizationURL(state, codeVerifier, { - scopes: ["users.read", "tweet.read"] -}); +const scopes = ["users.read", "tweet.read"]; +const url = twitter.createAuthorizationURL(state, codeVerifier, scopes); ``` ```ts -const tokens = await twitter.validateAuthorizationCode(code, codeVerifier); const response = await fetch("https://api.twitter.com/2/users/me", { headers: { - Authorization: `Bearer ${tokens.accessToken}` + Authorization: `Bearer ${accessToken}` } }); const user = await response.json(); ``` -## Get refresh token +## Refresh access tokens Add the `offline.access` scope to get refresh tokens. ```ts -const url = await twitter.createAuthorizationURL(state, codeVerifier, { - scopes: ["users.read", "tweet.read", "offline.access"] -}); +const scopes = ["offline.access"]; +const url = twitter.createAuthorizationURL(state, codeVerifier, state); +``` + +```ts +const tokens = await twitter.validateAuthorizationCode(code, codeVerifier); +const accessToken = tokens.accessToken(); +const accessTokenExpiresAt = tokens.accessTokenExpiresAt(); +const refreshToken = tokens.refreshToken(); +``` + +Use `refreshAccessToken()` to get a new access token using a refresh token. Twitter returns the same values as during the authorization code validation. This method also returns `OAuth2Tokens` and throws the same errors as `validateAuthorizationCode()` + +```ts +import { OAuth2RequestError, ArcticFetchError } from "arctic"; + +try { + const tokens = await twitter.refreshAccessToken(accessToken); + const accessToken = tokens.accessToken(); + const accessTokenExpiresAt = tokens.accessTokenExpiresAt(); + const refreshToken = tokens.refreshToken(); +} catch (e) { + if (e instanceof OAuth2RequestError) { + // Invalid authorization code, credentials, or redirect URI + } + if (e instanceof ArcticFetchError) { + // Failed to call `fetch()` + } + // Parse error +} +``` + +## Revoke tokens + +Use `revokeToken()` to revoke a token. This can throw the same errors as `validateAuthorizationCode()`. + +```ts +try { + await twitter.revokeToken(refreshToken); +} catch (e) { + if (e instanceof OAuth2RequestError) { + // Invalid authorization code, credentials, or redirect URI + } + if (e instanceof ArcticFetchError) { + // Failed to call `fetch()` + } + // Parse error +} ``` diff --git a/docs/pages/providers/vk.md b/docs/pages/providers/vk.md index 28ac5ed9..bc90b1cd 100644 --- a/docs/pages/providers/vk.md +++ b/docs/pages/providers/vk.md @@ -4,7 +4,11 @@ title: "VK" # VK -For usage, see [OAuth 2.0 provider](/guides/oauth2). +OAuth 2.0 provider for VK. + +Also see the [OAuth 2.0](/guides/oauth2) guide. + +## Initialization ```ts import { VK } from "arctic"; @@ -12,12 +16,43 @@ import { VK } from "arctic"; const vk = new VK(clientId, clientSecret, redirectURI); ``` +## Create authorization URL + +Optionally use the `offline` scope to get access tokens with no expiration. + ```ts -const url: URL = await vk.createAuthorizationURL(state, { - // optional - scopes -}); -const tokens: VKTokens = await vk.validateAuthorizationCode(code); +import { generateState } from "arctic"; + +const state = generateState(); +const scopes = ["email", "messages", "offline"]; +const url = vk.createAuthorizationURL(state, scopes); +``` + +## Validate authorization code + +`validateAuthorizationCode()` will either return an [`OAuth2Tokens`](/reference/main/OAuth2Tokens), or throw one of [`OAuth2RequestError`](/reference/main/OAuth2RequestError), [`ArcticFetchError`](/reference/main/ArcticFetchError), or a standard `Error` (parse errors). VK will return an access token. + +```ts +import { OAuth2RequestError, ArcticFetchError } from "arctic"; + +try { + const tokens = await vk.validateAuthorizationCode(code); + const accessToken = tokens.accessToken(); + // Only if `offline` scope is not used. + const accessTokenExpiresAt = tokens.accessTokenExpiresAt(); +} catch (e) { + if (e instanceof OAuth2RequestError) { + // Invalid authorization code, credentials, or redirect URI + const code = e.code; + // ... + } + if (e instanceof ArcticFetchError) { + // Failed to call `fetch()` + const cause = e.cause; + // ... + } + // Parse error +} ``` ## Get user profile @@ -25,10 +60,9 @@ const tokens: VKTokens = await vk.validateAuthorizationCode(code); Use the [`users.get` endpoint](https://dev.vk.com/en/method/users.get). ```ts -const tokens = await vk.validateAuthorizationCode(code); const response = await fetch("https://api.vk.com/method/users.get", { headers: { - Authorization: `Bearer ${tokens.accessToken}` + Authorization: `Bearer ${accessToken}` } }); const user = await response.json(); diff --git a/docs/pages/providers/workos.md b/docs/pages/providers/workos.md index 98bd818d..bb4818da 100644 --- a/docs/pages/providers/workos.md +++ b/docs/pages/providers/workos.md @@ -4,7 +4,11 @@ title: "WorkOS" # WorkOS -For usage, see [OAuth 2.0 provider](/guides/oauth2). +OAuth 2.0 provider for WorkOS. + +Also see the [OAuth 2.0](/guides/oauth2) guide. + +## Initialization ```ts import { WorkOS } from "arctic"; @@ -12,20 +16,51 @@ import { WorkOS } from "arctic"; const workos = new WorkOS(clientId, clientSecret, redirectURI); ``` +## Create authorization URL + ```ts -const url: URL = await workos.createAuthorizationURL(state); -const tokens: WorkOSTokens = await workos.validateAuthorizationCode(code); +import { generateState } from "arctic"; + +const state = generateState(); +const url = workos.createAuthorizationURL(state); ``` -## Get user profile +## Validate authorization code -Use the [`/sso/profile` endpoint](https://workos.com/docs/reference/sso/profile/get-user-profile). +`validateAuthorizationCode()` will either return an [`OAuth2Tokens`](/reference/main/OAuth2Tokens), or throw one of [`OAuth2RequestError`](/reference/main/OAuth2RequestError), [`ArcticFetchError`](/reference/main/ArcticFetchError), or a standard `Error` (parse errors). WorkOS will only return an access token (no expiration). ```ts -const response = await fetch("https://api.workos.com/sso/profile", { - headers: { - Authorization: `Bearer ${tokens.accessToken}` +import { OAuth2RequestError, ArcticFetchError } from "arctic"; + +try { + const tokens = await workos.validateAuthorizationCode(code); + const accessToken = tokens.accessToken(); +} catch (e) { + if (e instanceof OAuth2RequestError) { + // Invalid authorization code, credentials, or redirect URI + const code = e.code; + // ... + } + if (e instanceof ArcticFetchError) { + // Failed to call `fetch()` + const cause = e.cause; + // ... } -}); -const user = await response.json(); + // Parse error +} +``` + +## Get user profile + +The [profile](https://workos.com/docs/reference/sso/profile) is included in the token response. + +```ts +const tokens = await workos.validateAuthorizationCode(code); +if ( + "profile" in tokens.data && + typeof tokens.data.profile === "object" && + tokens.data.profile !== null +) { + const profile = tokens.data.profile; +} ``` diff --git a/docs/pages/providers/yahoo.md b/docs/pages/providers/yahoo.md index 4abec1b6..68e8b74e 100644 --- a/docs/pages/providers/yahoo.md +++ b/docs/pages/providers/yahoo.md @@ -4,9 +4,11 @@ title: "Yahoo" # Yahoo -Implements OpenID Connect. +OAuth 2.0 provider for Yahoo. -For usage, see [OAuth 2.0 provider](/guides/oauth2). +Also see the [OAuth 2.0 with PKCE](/guides/oauth2-pkce) guide. + +## Initialization ```ts import { Yahoo } from "arctic"; @@ -14,33 +16,97 @@ import { Yahoo } from "arctic"; const yahoo = new Yahoo(clientId, clientSecret, redirectURI); ``` +## Create authorization URL + ```ts -const url: URL = await yahoo.createAuthorizationURL(state, { - // optional - scopes -}); -const tokens: YahooTokens = await yahoo.validateAuthorizationCode(code); -const tokens: YahooTokens = await yahoo.refreshAccessToken(refreshToken); +import { generateState, generateCodeVerifier } from "arctic"; + +const state = generateState(); +const codeVerifier = generateCodeVerifier(); +const scopes = ["openid", "profile"]; +const url = yahoo.createAuthorizationURL(state, codeVerifier, scopes); ``` -## Get user profile +## Validate authorization code -Add the `profile` scope. Optionally add the `email` scope to get user email. +`validateAuthorizationCode()` will either return an [`OAuth2Tokens`](/reference/main/OAuth2Tokens), or throw one of [`OAuth2RequestError`](/reference/main/OAuth2RequestError), [`ArcticFetchError`](/reference/main/ArcticFetchError), or a standard `Error` (parse errors). Yahoo returns an access token, the access token expiration, and a refresh token. ```ts -const url = await yahoo.createAuthorizationURL(state, { - scopes: ["profile", "email"] -}); +import { OAuth2RequestError, ArcticFetchError } from "arctic"; + +try { + const tokens = await yahoo.validateAuthorizationCode(code, codeVerifier); + const accessToken = tokens.accessToken(); + const accessTokenExpiresAt = tokens.accessTokenExpiresAt(); + const refreshToken = tokens.refreshToken(); +} catch (e) { + if (e instanceof OAuth2RequestError) { + // Invalid authorization code, credentials, or redirect URI + const code = e.code; + // ... + } + if (e instanceof ArcticFetchError) { + // Failed to call `fetch()` + const cause = e.cause; + // ... + } + // Parse error +} ``` -Parse the ID token or use the `userinfo` endpoint. See [ID token claims](https://developer.yahoo.com/sign-in-with-yahoo/#get-user-info-api). +## Refresh access tokens + +Use `refreshAccessToken()` to get a new access token using a refresh token. Yahoo returns the same values as during the authorization code validation. This method also returns `OAuth2Tokens` and throws the same errors as `validateAuthorizationCode()` + +```ts +import { OAuth2RequestError, ArcticFetchError } from "arctic"; + +try { + const tokens = await yahoo.refreshAccessToken(accessToken); + const accessToken = tokens.accessToken(); + const accessTokenExpiresAt = tokens.accessTokenExpiresAt(); +} catch (e) { + if (e instanceof OAuth2RequestError) { + // Invalid authorization code, credentials, or redirect URI + } + if (e instanceof ArcticFetchError) { + // Failed to call `fetch()` + } + // Parse error +} +``` + +## OpenID Connect + +Use OpenID Connect with the `openid` scope to get the user's profile with an ID token or the [`userinfo` endpoint](https://developer.yahoo.com/sign-in-with-yahoo/#get-user-info-api). Arctic provides [`decodeIdToken()`](/reference/main/decodeIdToken) for decoding the token's payload. + +```ts +const scopes = ["openid"]; +const url = yahoo.createAuthorizationURL(state, codeVerifier, scopes); +``` + +```ts +import { decodeIdToken } from "arctic"; + +const tokens = await yahoo.validateAuthorizationCode(code, codeVerifier); +const idToken = tokens.idToken(); +const claims = decodeIdToken(idToken); +``` ```ts -const tokens = await yahoo.validateAuthorizationCode(code); const response = await fetch("https://api.login.yahoo.com/openid/v1/userinfo", { headers: { - Authorization: `Bearer ${tokens.accessToken}` + Authorization: `Bearer ${accessToken}` } }); const user = await response.json(); ``` + +### Get user profile + +Make sure to add the `profile` scope to get the user profile and the `email` scope to get the user email. + +```ts +const scopes = ["openid", "profile", "email"]; +const url = yahoo.createAuthorizationURL(state, codeVerifier, scopes); +``` diff --git a/docs/pages/providers/yandex.md b/docs/pages/providers/yandex.md index 35274af0..42f0ba66 100644 --- a/docs/pages/providers/yandex.md +++ b/docs/pages/providers/yandex.md @@ -4,24 +4,76 @@ title: "Yandex" # Yandex -For usage, see [OAuth 2.0 provider](/guides/oauth2). +OAuth 2.0 provider for Yandex. + +Also see the [OAuth 2.0](/guides/oauth2) guide. + +## Initialization ```ts import { Yandex } from "arctic"; -const yandex = new Yandex(clientId, clientSecret, { - // optional - redirectURI -}); +const yandex = new Yandex(clientId, clientSecret, redirectURI); ``` +## Create authorization URL + ```ts -const url: URL = await yandex.createAuthorizationURL(state, { - // optional - scopes -}); -const tokens: YandexTokens = await yandex.validateAuthorizationCode(code); -const tokens: YandexTokens = await yandex.refreshAccessToken(refreshToken); +import { generateState } from "arctic"; + +const state = generateState(); +const scopes = ["activity:write", "read"]; +const url = yandex.createAuthorizationURL(state, scopes); +``` + +## Validate authorization code + +`validateAuthorizationCode()` will either return an [`OAuth2Tokens`](/reference/main/OAuth2Tokens), or throw one of [`OAuth2RequestError`](/reference/main/OAuth2RequestError), [`ArcticFetchError`](/reference/main/ArcticFetchError), or a standard `Error` (parse errors). Yandex returns an access token, the access token expiration, and a refresh token. + +```ts +import { OAuth2RequestError, ArcticFetchError } from "arctic"; + +try { + const tokens = await yandex.validateAuthorizationCode(code); + const accessToken = tokens.accessToken(); + const accessTokenExpiresAt = tokens.accessTokenExpiresAt(); + const refreshToken = tokens.refreshToken(); +} catch (e) { + if (e instanceof OAuth2RequestError) { + // Invalid authorization code, credentials, or redirect URI + const code = e.code; + // ... + } + if (e instanceof ArcticFetchError) { + // Failed to call `fetch()` + const cause = e.cause; + // ... + } + // Parse error +} +``` + +## Refresh access tokens + +Use `refreshAccessToken()` to get a new access token using a refresh token. Yandex returns the same values as during the authorization code validation. This method also returns `OAuth2Tokens` and throws the same errors as `validateAuthorizationCode()` + +```ts +import { OAuth2RequestError, ArcticFetchError } from "arctic"; + +try { + const tokens = await yandex.refreshAccessToken(accessToken); + const accessToken = tokens.accessToken(); + const accessTokenExpiresAt = tokens.accessTokenExpiresAt(); + const refreshToken = tokens.refreshToken(); +} catch (e) { + if (e instanceof OAuth2RequestError) { + // Invalid authorization code, credentials, or redirect URI + } + if (e instanceof ArcticFetchError) { + // Failed to call `fetch()` + } + // Parse error +} ``` ## Get user profile @@ -31,7 +83,7 @@ Use the [`/myself` endpoint](https://docs.github.com/en/rest/users/users?apiVers ```ts const response = await fetch("https://api.tracker.yandex.net/v2/myself", { headers: { - Authorization: `OAuth ${tokens.accessToken}`, + Authorization: `OAuth ${accessToken}`, "X-Org-ID": ORGANIZATION_ID } }); diff --git a/docs/pages/providers/zoom.md b/docs/pages/providers/zoom.md index 0473b90a..4950b303 100644 --- a/docs/pages/providers/zoom.md +++ b/docs/pages/providers/zoom.md @@ -4,9 +4,11 @@ title: "Zoom" # Zoom -Implements OpenID Connect. +OAuth 2.0 provider for Zoom. -For usage, see [OAuth 2.0 provider with PKCE](/guides/oauth2-pkce). +Also see the [OAuth 2.0](/guides/oauth2) guide. + +## Initialization ```ts import { Zoom } from "arctic"; @@ -14,31 +16,94 @@ import { Zoom } from "arctic"; const zoom = new Zoom(clientId, clientSecret, redirectURI); ``` +## Create authorization URL + ```ts -const url: URL = await zoom.createAuthorizationURL(state, codeVerifier, { - // optional - scopes -}); -const tokens: ZoomTokens = await zoom.validateAuthorizationCode(code, codeVerifier); -const tokens: ZoomTokens = await zoom.refreshAccessToken(refreshToken); +import { generateState, generateCodeVerifier } from "arctic"; + +const state = generateState(); +const codeVerifier = generateCodeVerifier(); +const scopes = ["user:read:email"]; +const url = zoom.createAuthorizationURL(state, codeVerifier, scopes); ``` -## Get user profile +## Validate authorization code -Add the `user:read` scope and use the [`/users/me` endpoint](https://developers.zoom.us/docs/api/rest/reference/user/methods/#operation/user). +`validateAuthorizationCode()` will either return an [`OAuth2Tokens`](/reference/main/OAuth2Tokens), or throw one of [`OAuth2RequestError`](/reference/main/OAuth2RequestError), [`ArcticFetchError`](/reference/main/ArcticFetchError), or a standard `Error` (parse errors). Zoom returns an access token, the access token expiration, and a refresh token. ```ts -const url = await zoom.createAuthorizationURL(state, codeVerifier, { - scopes: ["user:read"] -}); +import { OAuth2RequestError, ArcticFetchError } from "arctic"; + +try { + const tokens = await zoom.validateAuthorizationCode(code, codeVerifier); + const accessToken = tokens.accessToken(); + const accessTokenExpiresAt = tokens.accessTokenExpiresAt(); + const refreshToken = tokens.refreshToken(); +} catch (e) { + if (e instanceof OAuth2RequestError) { + // Invalid authorization code, credentials, or redirect URI + const code = e.code; + // ... + } + if (e instanceof ArcticFetchError) { + // Failed to call `fetch()` + const cause = e.cause; + // ... + } + // Parse error +} ``` +## Refresh access tokens + +Use `refreshAccessToken()` to get a new access token using a refresh token. Zoom returns the same values as during the authorization code validation. This method also returns `OAuth2Tokens` and throws the same errors as `validateAuthorizationCode()` + +```ts +import { OAuth2RequestError, ArcticFetchError } from "arctic"; + +try { + const tokens = await zoom.refreshAccessToken(accessToken); + const accessToken = tokens.accessToken(); + const accessTokenExpiresAt = tokens.accessTokenExpiresAt(); + const refreshToken = tokens.refreshToken(); +} catch (e) { + if (e instanceof OAuth2RequestError) { + // Invalid authorization code, credentials, or redirect URI + } + if (e instanceof ArcticFetchError) { + // Failed to call `fetch()` + } + // Parse error +} +``` + +## Get user profile + +Add the `user:read` scope in the app settings and use the [`/users/me` endpoint](https://developers.zoom.us/docs/api/rest/reference/user/methods/#operation/user). + ```ts -const tokens = await zoom.validateAuthorizationCode(code, codeVerifier); const response = await fetch("https://api.zoom.us/v2/users/me", { headers: { - Authorization: `Bearer ${tokens.accessToken}` + Authorization: `Bearer ${accessToken}` } }); const user = await response.json(); ``` + +## Revoke tokens + +Revoke tokens with `revokeToken()`. This can throw the same errors as `validateAuthorizationCode()`. + +```ts +try { + await zoom.revokeToken(token); +} catch (e) { + if (e instanceof OAuth2RequestError) { + // Invalid authorization code, credentials, or redirect URI + } + if (e instanceof ArcticFetchError) { + // Failed to call `fetch()` + } + // Parse error +} +``` diff --git a/docs/pages/reference/index.md b/docs/pages/reference/index.md new file mode 100644 index 00000000..4857d793 --- /dev/null +++ b/docs/pages/reference/index.md @@ -0,0 +1,9 @@ +--- +title: "API reference" +--- + +# API reference + +## Modules + +- [`arctic`](/reference/main) diff --git a/docs/pages/reference/main/ArcticFetchError.md b/docs/pages/reference/main/ArcticFetchError.md new file mode 100644 index 00000000..4af58b37 --- /dev/null +++ b/docs/pages/reference/main/ArcticFetchError.md @@ -0,0 +1,9 @@ +--- +title: "ArcticFetchError" +--- + +# ArcticFetchError + +Extends `Error`. + +Error indicating that the `fetch()` call failed. See `ArcticFetchError.cause` for the error thrown by `fetch()`. diff --git a/docs/pages/reference/main/OAuth2RequestError.md b/docs/pages/reference/main/OAuth2RequestError.md new file mode 100644 index 00000000..db92853e --- /dev/null +++ b/docs/pages/reference/main/OAuth2RequestError.md @@ -0,0 +1,25 @@ +--- +title: "OAuth2RequestError" +--- + +# OAuth2RequestError + +Extends `Error`. + +Error indicating that the provider returned an [OAuth 2.0 error response](https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.2.1). + +## Properties + +```ts +interface Properties { + code: string; + description: string | null; + uri: string | null; + state: string | null; +} +``` + +- `code`: The `error` field +- `description`: The `error_description` field +- `uri`: The `error_uri` field +- `state`: The `state` field diff --git a/docs/pages/reference/main/OAuth2Tokens.md/accessToken.md b/docs/pages/reference/main/OAuth2Tokens.md/accessToken.md new file mode 100644 index 00000000..a18450ce --- /dev/null +++ b/docs/pages/reference/main/OAuth2Tokens.md/accessToken.md @@ -0,0 +1,13 @@ +--- +title: "OAuth2Tokens.accessToken()" +--- + +# OAuth2Tokens.accessToken() + +Returns the `access_token` field value. Throws an `Error` if the field is missing or the value isn't a string. + +## Definition + +```ts +function accessToken(): string; +``` diff --git a/docs/pages/reference/main/OAuth2Tokens.md/accessTokenExpiresAt.md b/docs/pages/reference/main/OAuth2Tokens.md/accessTokenExpiresAt.md new file mode 100644 index 00000000..dcade1b4 --- /dev/null +++ b/docs/pages/reference/main/OAuth2Tokens.md/accessTokenExpiresAt.md @@ -0,0 +1,13 @@ +--- +title: "OAuth2Tokens.accessTokenExpiresAt()" +--- + +# OAuth2Tokens.accessTokenExpiresAt() + +Gets the `expires_in` field value and returns the expiration `Date`. Throws an `Error` if the field is missing or the value isn't a number. + +## Definition + +```ts +function accessTokenExpiresAt(): Date; +``` diff --git a/docs/pages/reference/main/OAuth2Tokens.md/accessTokenExpiresInSeconds.md b/docs/pages/reference/main/OAuth2Tokens.md/accessTokenExpiresInSeconds.md new file mode 100644 index 00000000..e725d66f --- /dev/null +++ b/docs/pages/reference/main/OAuth2Tokens.md/accessTokenExpiresInSeconds.md @@ -0,0 +1,13 @@ +--- +title: "OAuth2Tokens.accessTokenExpiresInSeconds()" +--- + +# OAuth2Tokens.accessTokenExpiresInSeconds() + +Returns the `expires_in` field value. Throws an `Error` if the field is missing or the value isn't a number. + +## Definition + +```ts +function accessTokenExpiresInSeconds(): number; +``` diff --git a/docs/pages/reference/main/OAuth2Tokens.md/hasRefreshToken.md b/docs/pages/reference/main/OAuth2Tokens.md/hasRefreshToken.md new file mode 100644 index 00000000..3acfaf0d --- /dev/null +++ b/docs/pages/reference/main/OAuth2Tokens.md/hasRefreshToken.md @@ -0,0 +1,13 @@ +--- +title: "OAuth2RequestResult.hasRefreshToken()" +--- + +# OAuth2RequestResult.hasRefreshToken() + +Returns `refresh_token` if the `error` field exists and the value is a string. + +## Definition + +```ts +function hasRefreshToken(): boolean; +``` diff --git a/docs/pages/reference/main/OAuth2Tokens.md/idToken.md b/docs/pages/reference/main/OAuth2Tokens.md/idToken.md new file mode 100644 index 00000000..760e4870 --- /dev/null +++ b/docs/pages/reference/main/OAuth2Tokens.md/idToken.md @@ -0,0 +1,13 @@ +--- +title: "OAuth2Tokens.idToken()" +--- + +# OAuth2Tokens.idToken() + +Returns the `id_token` field value. Throws an `Error` if the field is missing or the value isn't a string. + +## Definition + +```ts +function idToken(): string; +``` diff --git a/docs/pages/reference/main/OAuth2Tokens.md/index.md b/docs/pages/reference/main/OAuth2Tokens.md/index.md new file mode 100644 index 00000000..73535c84 --- /dev/null +++ b/docs/pages/reference/main/OAuth2Tokens.md/index.md @@ -0,0 +1,37 @@ +--- +title: "OAuth2Tokens" +--- + +# OAuth2Tokens + +Represents a JSON-parsed successful token response body. + +## Constructor + +```ts +function constructor(data: object): this; +``` + +### Parameters + +- `data`: JSON-parsed successful response body. + +## Methods + +- [`accessToken()`](/reference/main/OAuth2Tokens/accessToken) +- [`accessTokenExpiresAt()`](/reference/main/OAuth2Tokens/accessTokenExpiresAt) +- [`accessTokenExpiresInSeconds()`](/reference/main/OAuth2Tokens/accessTokenExpiresInSeconds) +- [`hasRefreshToken()`](/reference/main/OAuth2Tokens/hasRefreshToken) +- [`refreshToken()`](/reference/main/OAuth2Tokens/refreshToken) +- [`refreshTokenExpiresAt()`](/reference/main/OAuth2Tokens/refreshTokenExpiresAt) +- [`refreshTokenExpiresInSeconds()`](/reference/main/OAuth2Tokens/refreshTokenExpiresInSeconds) + +## Properties + +```ts +interface Properties { + data: object; +} +``` + +- `data`: The raw JSON-parsed response body. diff --git a/docs/pages/reference/main/OAuth2Tokens.md/refreshToken.md b/docs/pages/reference/main/OAuth2Tokens.md/refreshToken.md new file mode 100644 index 00000000..b9aeb9f9 --- /dev/null +++ b/docs/pages/reference/main/OAuth2Tokens.md/refreshToken.md @@ -0,0 +1,13 @@ +--- +title: "OAuth2Tokens.refreshToken()" +--- + +# OAuth2Tokens.refreshToken() + +Returns the `refresh_token` field value. Throws an `Error` if the field is missing or the value isn't a string. + +## Definition + +```ts +function refreshToken(): string; +``` diff --git a/docs/pages/reference/main/OAuth2Tokens.md/refreshTokenExpiresAt.md b/docs/pages/reference/main/OAuth2Tokens.md/refreshTokenExpiresAt.md new file mode 100644 index 00000000..2d60fcbb --- /dev/null +++ b/docs/pages/reference/main/OAuth2Tokens.md/refreshTokenExpiresAt.md @@ -0,0 +1,13 @@ +--- +title: "OAuth2Tokens.refreshTokenExpiresAt()" +--- + +# OAuth2Tokens.refreshTokenExpiresAt() + +Gets the `refresh_token_expires_in` field value and returns the expiration `Date`. Throws an `Error` if the field is missing or the value isn't a number. + +## Definition + +```ts +function refreshTokenExpiresAt(): Date; +``` diff --git a/docs/pages/reference/main/OAuth2Tokens.md/refreshTokenExpiresInSeconds.md b/docs/pages/reference/main/OAuth2Tokens.md/refreshTokenExpiresInSeconds.md new file mode 100644 index 00000000..84c4a475 --- /dev/null +++ b/docs/pages/reference/main/OAuth2Tokens.md/refreshTokenExpiresInSeconds.md @@ -0,0 +1,13 @@ +--- +title: "OAuth2Tokens.refreshTokenExpiresInSeconds()" +--- + +# OAuth2Tokens.refreshTokenExpiresInSeconds() + +Returns the `refresh_token_expires_in` field value. Throws an `Error` if the field is missing or the value isn't a number. + +## Definition + +```ts +function refreshTokenExpiresInSeconds(): number; +``` diff --git a/docs/pages/reference/main/decodeIdToken.md b/docs/pages/reference/main/decodeIdToken.md new file mode 100644 index 00000000..d996f47c --- /dev/null +++ b/docs/pages/reference/main/decodeIdToken.md @@ -0,0 +1,17 @@ +--- +title: "decodeIdToken()" +--- + +# decodeIdToken + +Decodes the ID token payload. This does not validate the signature. Throws an `Error` if the token is malformed. + +## Definition + +```ts +function decodeIdToken(idToken: string): object; +``` + +### Parameters + +- `idToken` diff --git a/docs/pages/reference/main/index.md b/docs/pages/reference/main/index.md new file mode 100644 index 00000000..623839bc --- /dev/null +++ b/docs/pages/reference/main/index.md @@ -0,0 +1,17 @@ +--- +title: "arctic" +--- + +# arctic + +## Classes + +- [`ArcticFetchError`](/reference/main/ArcticFetchError) +- [`OAuth2RequestError`](/reference/main/OAuth2RequestError) +- [`OAuth2Tokens`](/reference/main/OAuth2Tokens) + +## Functions + +- [`generateCodeVerifier()`](https://oauth2.oslojs.dev/reference/main/generateCodeVerifier) +- [`generateState()`](https://oauth2.oslojs.dev/reference/main/generateState) +- [`decodeIdToken()`](/reference/main/decodeIdToken) diff --git a/package.json b/package.json index c7fc43f6..05c84e9e 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "arctic", "type": "module", - "version": "1.9.1", + "version": "2.0.0-next.5", "description": "OAuth 2.0 clients for popular providers", "main": "dist/index.js", "types": "dist/index.d.ts", @@ -25,12 +25,15 @@ "@types/node": "^20.8.6", "@typescript-eslint/eslint-plugin": "^6.7.5", "@typescript-eslint/parser": "^6.7.5", - "auri": "1.0.2", + "auri": "2.0.0", "eslint": "^8.51.0", "prettier": "^3.0.3", - "typescript": "^5.2.2" + "typescript": "^5.2.2", + "vitest": "1.6.0" }, "dependencies": { - "oslo": "1.2.0" + "@oslojs/crypto": "0.6.0", + "@oslojs/encoding": "0.3.0", + "@oslojs/oauth2": "0.5.0" } } diff --git a/src/index.ts b/src/index.ts index 3ebeb2d1..844e0851 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,4 @@ +export { FortyTwo } from "./providers/42.js"; export { AmazonCognito } from "./providers/amazon-cognito.js"; export { AniList } from "./providers/anilist.js"; export { Apple } from "./providers/apple.js"; @@ -12,12 +13,12 @@ export { Dribbble } from "./providers/dribbble.js"; export { Dropbox } from "./providers/dropbox.js"; export { Facebook } from "./providers/facebook.js"; export { Figma } from "./providers/figma.js"; -export { Intuit } from "./providers/intuit.js"; export { GitHub } from "./providers/github.js"; export { GitLab } from "./providers/gitlab.js"; export { Google } from "./providers/google.js"; +export { Intuit } from "./providers/intuit.js"; export { Kakao } from "./providers/kakao.js"; -export { Keycloak } from "./providers/keycloak.js"; +export { KeyCloak } from "./providers/keycloak.js"; export { Lichess } from "./providers/lichess.js"; export { Line } from "./providers/line.js"; export { Linear } from "./providers/linear.js"; @@ -33,6 +34,7 @@ export { Roblox } from "./providers/roblox.js"; export { Salesforce } from "./providers/salesforce.js"; export { Shikimori } from "./providers/shikimori.js"; export { Slack } from "./providers/slack.js"; +export { SoundCloud } from "./providers/soundcloud.js"; export { Spotify } from "./providers/spotify.js"; export { Strava } from "./providers/strava.js"; export { Tiltify } from "./providers/tiltify.js"; @@ -44,77 +46,8 @@ export { WorkOS } from "./providers/workos.js"; export { Yahoo } from "./providers/yahoo.js"; export { Yandex } from "./providers/yandex.js"; export { Zoom } from "./providers/zoom.js"; -export { FortyTwo } from "./providers/42.js"; - -export type { - AmazonCognitoRefreshedTokens, - AmazonCognitoTokens -} from "./providers/amazon-cognito.js"; -export type { AniListTokens } from "./providers/anilist.js"; -export type { AppleCredentials, AppleRefreshedTokens, AppleTokens } from "./providers/apple.js"; -export type { AtlassianTokens } from "./providers/atlassian.js"; -export type { Auth0Tokens } from "./providers/auth0.js"; -export type { AuthentikTokens } from "./providers/authentik.js"; -export type { BitbucketTokens } from "./providers/bitbucket.js"; -export type { BoxTokens } from "./providers/box.js"; -export type { CoinbaseTokens } from "./providers/coinbase.js"; -export type { DiscordTokens } from "./providers/discord.js"; -export type { DribbbleTokens } from "./providers/dribbble.js"; -export type { DropboxRefreshedTokens, DropboxTokens } from "./providers/dropbox.js"; -export type { FacebookTokens } from "./providers/facebook.js"; -export type { FigmaRefreshedTokens, FigmaTokens } from "./providers/figma.js"; -export type { IntuitTokens } from "./providers/intuit.js"; -export type { GitHubTokens } from "./providers/github.js"; -export type { GitLabTokens } from "./providers/gitlab.js"; -export type { GoogleRefreshedTokens, GoogleTokens } from "./providers/google.js"; -export type { KakaoTokens } from "./providers/kakao.js"; -export type { KeycloakTokens } from "./providers/keycloak.js"; -export type { LichessTokens } from "./providers/lichess.js"; -export type { LineRefreshedTokens, LineTokens } from "./providers/line.js"; -export type { LinearTokens } from "./providers/linear.js"; -export type { LinkedInTokens } from "./providers/linkedin.js"; -export type { MicrosoftEntraIdTokens } from "./providers/microsoft-entra-id.js"; -export type { MyAnimeListTokens } from "./providers/myanimelist.js"; -export type { NotionTokens } from "./providers/notion.js"; -export type { OktaTokens } from "./providers/okta.js"; -export type { OsuTokens } from "./providers/osu.js"; -export type { PatreonTokens } from "./providers/patreon.js"; -export type { RedditTokens } from "./providers/reddit.js"; -export type { RobloxTokens } from "./providers/roblox.js"; -export type { SalesforceTokens } from "./providers/salesforce.js"; -export type { ShikimoriTokens } from "./providers/shikimori.js"; -export type { SlackTokens } from "./providers/slack.js"; -export type { SpotifyTokens } from "./providers/spotify.js"; -export type { StravaTokens } from "./providers/strava.js"; -export type { TiltifyTokens } from "./providers/tiltify.js"; -export type { TumblrTokens } from "./providers/tumblr.js"; -export type { TwitchTokens } from "./providers/twitch.js"; -export type { TwitterTokens } from "./providers/twitter.js"; -export type { VKTokens } from "./providers/vk.js"; -export type { WorkOSTokens } from "./providers/workos.js"; -export type { YahooTokens } from "./providers/yahoo.js"; -export type { YandexTokens } from "./providers/yandex.js"; -export type { ZoomTokens } from "./providers/zoom.js"; -export type { FortyTwoTokens } from "./providers/42.js"; - -export { generateCodeVerifier, generateState, OAuth2RequestError } from "oslo/oauth2"; - -export interface OAuth2Provider { - createAuthorizationURL(state: string): Promise; - validateAuthorizationCode(code: string): Promise; - refreshAccessToken?(refreshToken: string): Promise; -} -export interface OAuth2ProviderWithPKCE { - createAuthorizationURL(state: string, codeVerifier: string): Promise; - validateAuthorizationCode(code: string, codeVerifier: string): Promise; - refreshAccessToken?(refreshToken: string): Promise; -} +export { OAuth2Tokens, generateCodeVerifier, generateState } from "./oauth2.js"; +export { decodeIdToken } from "./oidc.js"; +export { ArcticFetchError, OAuth2RequestError } from "./request.js"; -export interface Tokens { - accessToken: string; - refreshToken?: string | null; - accessTokenExpiresAt?: Date; - refreshTokenExpiresAt?: Date | null; - idToken?: string; -} diff --git a/src/oauth2.ts b/src/oauth2.ts new file mode 100644 index 00000000..f2e09bca --- /dev/null +++ b/src/oauth2.ts @@ -0,0 +1,84 @@ +import { base64url } from "@oslojs/encoding"; +import { sha256 } from "@oslojs/crypto/sha2"; +import { TokenRequestResult } from "@oslojs/oauth2"; + +export class OAuth2Tokens { + public data: object; + + private result: TokenRequestResult; + + constructor(data: object) { + this.data = data; + this.result = new TokenRequestResult(data); + } + + public tokenType(): string { + return this.result.tokenType(); + } + + public accessToken(): string { + return this.result.accessToken(); + } + + public accessTokenExpiresInSeconds(): number { + return this.result.accessTokenExpiresInSeconds(); + } + + public accessTokenExpiresAt(): Date { + return this.result.accessTokenExpiresAt(); + } + + public hasRefreshToken(): boolean { + return this.result.hasRefreshToken(); + } + + public refreshToken(): string { + return this.result.refreshToken(); + } + + public refreshTokenExpiresInSeconds(): number { + if ( + "refresh_token_expires_in" in this.data && + typeof this.data.refresh_token_expires_in === "number" + ) { + return this.data.refresh_token_expires_in; + } + throw new Error("Missing or invalid 'refresh_token_expires_in' field"); + } + + public refreshTokenExpiresAt(): Date { + return new Date(Date.now() + this.refreshTokenExpiresInSeconds() * 1000); + } + + public hasScopes(): boolean { + return this.result.hasScopes(); + } + + public scopes(): string[] { + return this.result.scopes(); + } + + public idToken(): string { + if ("id_token" in this.data && typeof this.data.id_token === "string") { + return this.data.id_token; + } + throw new Error("Missing or invalid field 'id_token'"); + } +} + +export function createS256CodeChallenge(codeVerifier: string): string { + const codeChallengeBytes = sha256(new TextEncoder().encode(codeVerifier)); + return base64url.encodeNoPadding(codeChallengeBytes); +} + +export function generateCodeVerifier(): string { + const randomValues = new Uint8Array(32); + crypto.getRandomValues(randomValues); + return base64url.encodeNoPadding(randomValues); +} + +export function generateState(): string { + const randomValues = new Uint8Array(32); + crypto.getRandomValues(randomValues); + return base64url.encodeNoPadding(randomValues); +} diff --git a/src/oidc.test.ts b/src/oidc.test.ts new file mode 100644 index 00000000..83715b15 --- /dev/null +++ b/src/oidc.test.ts @@ -0,0 +1,12 @@ +import { test, expect } from "vitest"; +import { decodeIdToken } from "./oidc.js"; + +test("decodeIdToken()", () => { + const jwt = + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c"; + expect(decodeIdToken(jwt)).toStrictEqual({ + sub: "1234567890", + name: "John Doe", + iat: 1516239022 + }); +}); diff --git a/src/oidc.ts b/src/oidc.ts new file mode 100644 index 00000000..582a36a1 --- /dev/null +++ b/src/oidc.ts @@ -0,0 +1,36 @@ +import { base64url } from "@oslojs/encoding"; + +export function decodeIdToken(idToken: string): object { + const parts = idToken.split("."); + if (parts.length !== 3) { + throw new Error("Invalid ID token"); + } + let header: unknown; + try { + header = JSON.parse(new TextDecoder().decode(base64url.decodeIgnorePadding(parts[0]))); + } catch { + throw new Error("Invalid ID token"); + } + if (typeof header !== "object" || header === null) { + throw new Error("Invalid ID token"); + } + if (typeof header !== "object" || header === null) { + throw new Error("Invalid ID token"); + } + if (!("typ" in header) || typeof header.typ !== "string") { + throw new Error("Invalid ID token"); + } + if (header.typ !== "JWT") { + throw new Error("Invalid ID token"); + } + let payload: unknown; + try { + payload = JSON.parse(new TextDecoder().decode(base64url.decodeIgnorePadding(parts[1]))); + } catch { + throw new Error("Invalid ID token"); + } + if (typeof payload !== "object" || payload === null) { + throw new Error("Invalid ID token"); + } + return payload; +} diff --git a/src/providers/42.ts b/src/providers/42.ts index 3cf107df..5eca0d7a 100644 --- a/src/providers/42.ts +++ b/src/providers/42.ts @@ -1,56 +1,40 @@ -import { OAuth2Client } from "oslo/oauth2"; +import { createOAuth2Request, sendTokenRequest } from "../request.js"; -import type { OAuth2Provider } from "../index.js"; -import { createDate, TimeSpan } from "oslo"; +import type { OAuth2Tokens } from "../oauth2.js"; -const authorizeEndpoint = "https://api.intra.42.fr/oauth/authorize"; +const authorizationEndpoint = "https://api.intra.42.fr/oauth/authorize"; const tokenEndpoint = "https://api.intra.42.fr/oauth/token"; -export class FortyTwo implements OAuth2Provider { - private client: OAuth2Client; +export class FortyTwo { + private clientId: string; private clientSecret: string; + private redirectURI: string; constructor(clientId: string, clientSecret: string, redirectURI: string) { - this.client = new OAuth2Client(clientId, authorizeEndpoint, tokenEndpoint, { - redirectURI - }); + this.clientId = clientId; this.clientSecret = clientSecret; + this.redirectURI = redirectURI; } - public async createAuthorizationURL( - state: string, - options?: { - scopes?: string[]; - } - ): Promise { - return await this.client.createAuthorizationURL({ - state, - scopes: options?.scopes ?? [] - }); + public createAuthorizationURL(state: string, scopes: string[]): URL { + const url = new URL(authorizationEndpoint); + url.searchParams.set("response_type", "code"); + url.searchParams.set("client_id", this.clientId); + url.searchParams.set("state", state); + url.searchParams.set("scope", scopes.join(" ")); + url.searchParams.set("redirect_uri", this.redirectURI); + return url; } - public async validateAuthorizationCode(code: string): Promise { - const result = await this.client.validateAuthorizationCode(code, { - authenticateWith: "request_body", - credentials: this.clientSecret - }); - const tokens: FortyTwoTokens = { - accessToken: result.access_token, - accessTokenExpiresAt: createDate(new TimeSpan(result.expires_in, "s")) - }; + public async validateAuthorizationCode(code: string): Promise { + const body = new URLSearchParams(); + body.set("grant_type", "authorization_code"); + body.set("code", code); + body.set("redirect_uri", this.redirectURI); + body.set("client_id", this.clientId); + body.set("client_secret", this.clientSecret); + const request = createOAuth2Request(tokenEndpoint, body); + const tokens = await sendTokenRequest(request); return tokens; } } - -interface TokenResponseBody { - access_token: string; - token_type: string; - expires_in: number; - scope: string; - created_at: number; -} - -export interface FortyTwoTokens { - accessToken: string; - accessTokenExpiresAt: Date; -} diff --git a/src/providers/amazon-cognito.ts b/src/providers/amazon-cognito.ts index a91c3aaf..e90af63a 100644 --- a/src/providers/amazon-cognito.ts +++ b/src/providers/amazon-cognito.ts @@ -1,91 +1,78 @@ -import { TimeSpan, createDate } from "oslo"; -import { OAuth2Client } from "oslo/oauth2"; +import { createS256CodeChallenge } from "../oauth2.js"; +import { + createOAuth2Request, + encodeBasicCredentials, + sendTokenRequest, + sendTokenRevocationRequest +} from "../request.js"; -import type { OAuth2ProviderWithPKCE } from "../index.js"; +import type { OAuth2Tokens } from "../oauth2.js"; -export class AmazonCognito implements OAuth2ProviderWithPKCE { - private client: OAuth2Client; +export class AmazonCognito { + private authorizationEndpoint: string; + private tokenEndpoint: string; + private tokenRevocationEndpoint: string; + + private clientId: string; private clientSecret: string; + private redirectURI: string; + + constructor(userPool: string, clientId: string, clientSecret: string, redirectURI: string) { + this.authorizationEndpoint = userPool + "/oauth2/authorize"; + this.tokenEndpoint = userPool + "/oauth2/token"; + this.tokenRevocationEndpoint = userPool + "/oauth2/revoke"; - constructor(userPoolDomain: string, clientId: string, clientSecret: string, redirectURI: string) { - const authorizeEndpoint = userPoolDomain + "/oauth2/authorize"; - const tokenEndpoint = userPoolDomain + "/oauth2/token"; - this.client = new OAuth2Client(clientId, authorizeEndpoint, tokenEndpoint, { - redirectURI - }); + this.clientId = clientId; this.clientSecret = clientSecret; + this.redirectURI = redirectURI; } - public async createAuthorizationURL( - state: string, - codeVerifier: string, - options?: { - scopes?: string[]; - } - ): Promise { - const scopes = options?.scopes ?? []; - return await this.client.createAuthorizationURL({ - state, - codeVerifier, - scopes: [...scopes, "openid"] - }); + public createAuthorizationURL(state: string, codeVerifier: string, scopes: string[]): URL { + const url = new URL(this.authorizationEndpoint); + url.searchParams.set("response_type", "code"); + url.searchParams.set("client_id", this.clientId); + url.searchParams.set("state", state); + url.searchParams.set("scope", scopes.join(" ")); + url.searchParams.set("redirect_uri", this.redirectURI); + const codeChallenge = createS256CodeChallenge(codeVerifier); + url.searchParams.set("code_challenge_method", "S256"); + url.searchParams.set("code_challenge", codeChallenge); + return url; } public async validateAuthorizationCode( code: string, codeVerifier: string - ): Promise { - const result = await this.client.validateAuthorizationCode( - code, - { - credentials: this.clientSecret, - codeVerifier - } - ); - const tokens: AmazonCognitoTokens = { - accessToken: result.access_token, - refreshToken: result.refresh_token, - accessTokenExpiresAt: createDate(new TimeSpan(result.expires_in, "s")), - idToken: result.id_token - }; + ): Promise { + const body = new URLSearchParams(); + body.set("grant_type", "authorization_code"); + body.set("code", code); + body.set("code_verifier", codeVerifier); + body.set("redirect_uri", this.redirectURI); + const request = createOAuth2Request(this.tokenEndpoint, body); + const encodedCredentials = encodeBasicCredentials(this.clientId, this.clientSecret); + request.headers.set("Authorization", `Basic ${encodedCredentials}`); + const tokens = await sendTokenRequest(request); return tokens; } - public async refreshAccessToken(refreshToken: string): Promise { - const result = await this.client.refreshAccessToken(refreshToken, { - credentials: this.clientSecret - }); - const tokens: AmazonCognitoRefreshedTokens = { - accessToken: result.access_token, - accessTokenExpiresAt: createDate(new TimeSpan(result.expires_in, "s")), - idToken: result.id_token - }; + public async refreshAccessToken(refreshToken: string): Promise { + const body = new URLSearchParams(); + body.set("grant_type", "refresh_token"); + body.set("refresh_token", refreshToken); + const request = createOAuth2Request(this.tokenEndpoint, body); + const encodedCredentials = encodeBasicCredentials(this.clientId, this.clientSecret); + request.headers.set("Authorization", `Basic ${encodedCredentials}`); + const tokens = await sendTokenRequest(request); return tokens; } -} - -interface AuthorizationCodeResponseBody { - access_token: string; - refresh_token: string; - expires_in: number; - id_token: string; -} -interface RefreshTokenResponseBody { - access_token: string; - expires_in: number; - id_token: string; -} - -export interface AmazonCognitoTokens { - accessToken: string; - refreshToken: string; - accessTokenExpiresAt: Date; - idToken: string; -} - -export interface AmazonCognitoRefreshedTokens { - accessToken: string; - accessTokenExpiresAt: Date; - idToken: string; + public async revokeToken(token: string): Promise { + const body = new URLSearchParams(); + body.set("token", token); + const request = createOAuth2Request(this.tokenRevocationEndpoint, body); + const encodedCredentials = encodeBasicCredentials(this.clientId, this.clientSecret); + request.headers.set("Authorization", `Basic ${encodedCredentials}`); + await sendTokenRevocationRequest(request); + } } diff --git a/src/providers/anilist.ts b/src/providers/anilist.ts index e22a3d51..aa011f42 100644 --- a/src/providers/anilist.ts +++ b/src/providers/anilist.ts @@ -1,41 +1,39 @@ -import { OAuth2Client } from "oslo/oauth2"; -import type { OAuth2Provider } from "../index.js"; +import { createOAuth2Request, encodeBasicCredentials, sendTokenRequest } from "../request.js"; -const authorizeEndpoint = "https://anilist.co/api/v2/oauth/authorize"; +import type { OAuth2Tokens } from "../oauth2.js"; + +const authorizationEndpoint = "https://anilist.co/api/v2/oauth/authorize"; const tokenEndpoint = "https://anilist.co/api/v2/oauth/token"; -export class AniList implements OAuth2Provider { - private client: OAuth2Client; +export class AniList { + private clientId: string; private clientSecret: string; + private redirectURI: string; constructor(clientId: string, clientSecret: string, redirectURI: string) { - this.client = new OAuth2Client(clientId, authorizeEndpoint, tokenEndpoint, { - redirectURI - }); + this.clientId = clientId; this.clientSecret = clientSecret; + this.redirectURI = redirectURI; } - public async createAuthorizationURL(state: string): Promise { - return await this.client.createAuthorizationURL({ - state - }); + public createAuthorizationURL(state: string): URL { + const url = new URL(authorizationEndpoint); + url.searchParams.set("response_type", "code"); + url.searchParams.set("client_id", this.clientId); + url.searchParams.set("state", state); + url.searchParams.set("redirect_uri", this.redirectURI); + return url; } - public async validateAuthorizationCode(code: string): Promise { - const result = await this.client.validateAuthorizationCode(code, { - credentials: this.clientSecret - }); - const tokens: AniListTokens = { - accessToken: result.access_token - }; + public async validateAuthorizationCode(code: string): Promise { + const body = new URLSearchParams(); + body.set("grant_type", "authorization_code"); + body.set("code", code); + body.set("redirect_uri", this.redirectURI); + const request = createOAuth2Request(tokenEndpoint, body); + const encodedCredentials = encodeBasicCredentials(this.clientId, this.clientSecret); + request.headers.set("Authorization", `Basic ${encodedCredentials}`); + const tokens = await sendTokenRequest(request); return tokens; } } - -interface TokenResponseBody { - access_token: string; -} - -export interface AniListTokens { - accessToken: string; -} diff --git a/src/providers/apple.ts b/src/providers/apple.ts index d47201ef..c77bca63 100644 --- a/src/providers/apple.ts +++ b/src/providers/apple.ts @@ -1,127 +1,95 @@ -import { TimeSpan, createDate } from "oslo"; -import { base64 } from "oslo/encoding"; -import { createJWT } from "oslo/jwt"; -import { OAuth2Client } from "oslo/oauth2"; +import { createOAuth2Request, sendTokenRequest } from "../request.js"; +import { base64url } from "@oslojs/encoding"; -import type { OAuth2Provider } from "../index.js"; +import type { OAuth2Tokens } from "../oauth2.js"; -const authorizeEndpoint = "https://appleid.apple.com/auth/authorize"; +const authorizationEndpoint = "https://appleid.apple.com/auth/authorize"; const tokenEndpoint = "https://appleid.apple.com/auth/token"; -export class Apple implements OAuth2Provider { - private client: OAuth2Client; - private credentials: AppleCredentials; +export class Apple { + private clientId: string; + private teamId: string; + private keyId: string; + private pkcs8PrivateKey: Uint8Array; + private redirectURI: string; - constructor(credentials: AppleCredentials, redirectURI: string) { - this.client = new OAuth2Client(credentials.clientId, authorizeEndpoint, tokenEndpoint, { - redirectURI - }); - this.credentials = credentials; + constructor( + clientId: string, + teamId: string, + keyId: string, + pkcs8PrivateKey: Uint8Array, + redirectURI: string + ) { + this.clientId = clientId; + this.teamId = teamId; + this.keyId = keyId; + this.pkcs8PrivateKey = pkcs8PrivateKey; + this.redirectURI = redirectURI; } - public async createAuthorizationURL( - state: string, - options?: { - scopes?: string[]; - } - ): Promise { - return await this.client.createAuthorizationURL({ - state, - scopes: options?.scopes - }); + public createAuthorizationURL(state: string, scopes: string[]): URL { + const url = new URL(authorizationEndpoint); + url.searchParams.set("response_type", "code"); + url.searchParams.set("client_id", this.clientId); + url.searchParams.set("state", state); + url.searchParams.set("scope", scopes.join(" ")); + url.searchParams.set("redirect_uri", this.redirectURI); + return url; } - public async validateAuthorizationCode(code: string): Promise { - const result = await this.client.validateAuthorizationCode( - code, - { - authenticateWith: "request_body", - credentials: await this.createClientSecret() - } - ); - const tokens: AppleTokens = { - accessToken: result.access_token, - refreshToken: result.refresh_token ?? null, - accessTokenExpiresAt: createDate(new TimeSpan(result.expires_in, "s")), - idToken: result.id_token - }; - return tokens; - } - - public async refreshAccessToken(refreshToken: string): Promise { - const result = await this.client.refreshAccessToken(refreshToken, { - authenticateWith: "request_body", - credentials: await this.createClientSecret() - }); - const tokens: AppleRefreshedTokens = { - accessToken: result.access_token, - accessTokenExpiresAt: createDate(new TimeSpan(result.expires_in, "s")), - idToken: result.id_token - }; + public async validateAuthorizationCode(code: string): Promise { + const body = new URLSearchParams(); + body.set("grant_type", "authorization_code"); + body.set("code", code); + body.set("redirect_uri", this.redirectURI); + body.set("client_id", this.clientId); + const clientSecret = await this.createClientSecret(); + body.set("client_secret", clientSecret); + const request = createOAuth2Request(tokenEndpoint, body); + const tokens = await sendTokenRequest(request); return tokens; } private async createClientSecret(): Promise { - const audience = "https://appleid.apple.com"; - const payload = {}; - const jwt = await createJWT("ES256", parsePKCS8PEM(this.credentials.certificate), payload, { - headers: { - kid: this.credentials.keyId + const privateKey = await crypto.subtle.importKey( + "pkcs8", + this.pkcs8PrivateKey, + { + name: "ECDSA", + namedCurve: "P-256" + }, + false, + ["sign"] + ); + const now = Math.floor(Date.now() / 1000); + const header = { + typ: "JWT", + alg: "ES256", + kid: this.keyId + }; + const payload = { + iss: this.teamId, + exp: now + 5 * 60, + aud: ["https://appleid.apple.com"], + sub: this.clientId, + iat: now + }; + const encodedHeader = base64url.encodeNoPadding( + new TextEncoder().encode(JSON.stringify(header)) + ); + const encodedPayload = base64url.encodeNoPadding( + new TextEncoder().encode(JSON.stringify(payload)) + ); + const signature = await crypto.subtle.sign( + { + name: "ECDSA", + hash: "SHA-256" }, - issuer: this.credentials.teamId, - includeIssuedTimestamp: true, - expiresIn: new TimeSpan(5, "m"), - audiences: [audience], - subject: this.credentials.clientId - }); + privateKey, + new TextEncoder().encode(encodedHeader + "." + encodedPayload) + ); + const encodedSignature = base64url.encodeNoPadding(new Uint8Array(signature)); + const jwt = encodedHeader + "." + encodedPayload + "." + encodedSignature; return jwt; } } - -interface AuthorizationCodeResponseBody { - access_token: string; - refresh_token?: string; - expires_in: number; - id_token: string; -} - -interface RefreshTokenResponseBody { - access_token: string; - refresh_token?: string; - expires_in: number; - id_token: string; -} - -export interface AppleTokens { - accessToken: string; - refreshToken: string | null; - accessTokenExpiresAt: Date; - idToken: string; -} - -export interface AppleRefreshedTokens { - accessToken: string; - accessTokenExpiresAt: Date; - idToken: string; -} - -export interface AppleCredentials { - clientId: string; - teamId: string; - keyId: string; - certificate: string; -} - -function parsePKCS8PEM(pkcs8: string): Uint8Array { - return base64.decode( - pkcs8 - .replace("-----BEGIN PRIVATE KEY-----", "") - .replace("-----END PRIVATE KEY-----", "") - .replaceAll("\r", "") - .replaceAll("\n", "") - .trim(), - { - strict: false - } - ); -} diff --git a/src/providers/atlassian.ts b/src/providers/atlassian.ts index e96f7106..8f373363 100644 --- a/src/providers/atlassian.ts +++ b/src/providers/atlassian.ts @@ -1,72 +1,53 @@ -import { OAuth2Client } from "oslo/oauth2"; -import { TimeSpan, createDate } from "oslo"; +import { createOAuth2Request, sendTokenRequest } from "../request.js"; -import type { OAuth2Provider } from "../index.js"; +import type { OAuth2Tokens } from "../oauth2.js"; -const authorizeEndpoint = "https://auth.atlassian.com/authorize"; +const authorizationEndpoint = "https://auth.atlassian.com/authorize"; const tokenEndpoint = "https://auth.atlassian.com/oauth/token"; -export class Atlassian implements OAuth2Provider { - private client: OAuth2Client; +export class Atlassian { + private clientId: string; private clientSecret: string; + private redirectURI: string; constructor(clientId: string, clientSecret: string, redirectURI: string) { - this.client = new OAuth2Client(clientId, authorizeEndpoint, tokenEndpoint, { - redirectURI - }); + this.clientId = clientId; this.clientSecret = clientSecret; + this.redirectURI = redirectURI; } - public async createAuthorizationURL( - state: string, - options?: { - scopes?: string[]; - } - ): Promise { - const url = await this.client.createAuthorizationURL({ - state, - scopes: options?.scopes - }); + public createAuthorizationURL(state: string, scopes: string[]): URL { + const url = new URL(authorizationEndpoint); + url.searchParams.set("response_type", "code"); + url.searchParams.set("client_id", this.clientId); + url.searchParams.set("state", state); + url.searchParams.set("scope", scopes.join(" ")); + url.searchParams.set("redirect_uri", this.redirectURI); url.searchParams.set("audience", "api.atlassian.com"); url.searchParams.set("prompt", "consent"); return url; } - public async validateAuthorizationCode(code: string): Promise { - const result = await this.client.validateAuthorizationCode(code, { - authenticateWith: "request_body", - credentials: this.clientSecret - }); - const tokens: AtlassianTokens = { - accessToken: result.access_token, - accessTokenExpiresAt: createDate(new TimeSpan(result.expires_in, "s")), - refreshToken: result.refresh_token ?? null - }; + public async validateAuthorizationCode(code: string): Promise { + const body = new URLSearchParams(); + body.set("grant_type", "authorization_code"); + body.set("code", code); + body.set("redirect_uri", this.redirectURI); + body.set("client_id", this.clientId); + body.set("client_secret", this.clientSecret); + const request = createOAuth2Request(tokenEndpoint, body); + const tokens = await sendTokenRequest(request); return tokens; } - public async refreshAccessToken(refreshToken: string): Promise { - const result = await this.client.refreshAccessToken(refreshToken, { - authenticateWith: "request_body", - credentials: this.clientSecret - }); - const tokens: AtlassianTokens = { - accessToken: result.access_token, - accessTokenExpiresAt: createDate(new TimeSpan(result.expires_in, "s")), - refreshToken: result.refresh_token ?? null - }; + public async refreshAccessToken(refreshToken: string): Promise { + const body = new URLSearchParams(); + body.set("grant_type", "refresh_token"); + body.set("refresh_token", refreshToken); + body.set("client_id", this.clientId); + body.set("client_secret", this.clientSecret); + const request = createOAuth2Request(tokenEndpoint, body); + const tokens = await sendTokenRequest(request); return tokens; } } - -interface TokenResponseBody { - access_token: string; - expires_in: number; - refresh_token?: string; -} - -export interface AtlassianTokens { - accessToken: string; - accessTokenExpiresAt: Date; - refreshToken: string | null; -} diff --git a/src/providers/auth0.ts b/src/providers/auth0.ts index 1144280a..eadfc842 100644 --- a/src/providers/auth0.ts +++ b/src/providers/auth0.ts @@ -1,68 +1,69 @@ -import { OAuth2Client } from "oslo/oauth2"; +import { + createOAuth2Request, + encodeBasicCredentials, + sendTokenRequest, + sendTokenRevocationRequest +} from "../request.js"; -import type { OAuth2Provider } from "../index.js"; +import type { OAuth2Tokens } from "../oauth2.js"; -export class Auth0 implements OAuth2Provider { - private client: OAuth2Client; - private appDomain: string; +export class Auth0 { + private authorizationEndpoint: string; + private tokenEndpoint: string; + private tokenRevocationEndpoint: string; + + private clientId: string; private clientSecret: string; + private redirectURI: string; - constructor(appDomain: string, clientId: string, clientSecret: string, redirectURI: string) { - this.appDomain = appDomain; - const authorizeEndpoint = this.appDomain + "/authorize"; - const tokenEndpoint = this.appDomain + "/oauth/token"; - this.client = new OAuth2Client(clientId, authorizeEndpoint, tokenEndpoint, { - redirectURI - }); + constructor(domain: string, clientId: string, clientSecret: string, redirectURI: string) { + this.authorizationEndpoint = `https://${domain}/authorize`; + this.tokenEndpoint = `https://${domain}/oauth/token`; + this.tokenRevocationEndpoint = `https://${domain}/oauth/revoke`; + this.clientId = clientId; this.clientSecret = clientSecret; + this.redirectURI = redirectURI; } - public async createAuthorizationURL( - state: string, - options?: { - scopes?: string[]; - } - ): Promise { - const scopes = options?.scopes ?? []; - return await this.client.createAuthorizationURL({ - state, - scopes: [...scopes, "openid"] - }); + public createAuthorizationURL(state: string, scopes: string[]): URL { + const url = new URL(this.authorizationEndpoint); + url.searchParams.set("response_type", "code"); + url.searchParams.set("client_id", this.clientId); + url.searchParams.set("state", state); + url.searchParams.set("scope", scopes.join(" ")); + url.searchParams.set("redirect_uri", this.redirectURI); + return url; } - public async validateAuthorizationCode(code: string): Promise { - const result = await this.client.validateAuthorizationCode(code, { - credentials: this.clientSecret - }); - const tokens: Auth0Tokens = { - accessToken: result.access_token, - refreshToken: result.refresh_token, - idToken: result.id_token - }; + public async validateAuthorizationCode(code: string): Promise { + const body = new URLSearchParams(); + body.set("grant_type", "authorization_code"); + body.set("code", code); + body.set("redirect_uri", this.redirectURI); + const request = createOAuth2Request(this.tokenEndpoint, body); + const encodedCredentials = encodeBasicCredentials(this.clientId, this.clientSecret); + request.headers.set("Authorization", `Basic ${encodedCredentials}`); + const tokens = await sendTokenRequest(request); return tokens; } - public async refreshAccessToken(refreshToken: string): Promise { - const result = await this.client.refreshAccessToken(refreshToken, { - credentials: this.clientSecret - }); - const tokens: Auth0Tokens = { - accessToken: result.access_token, - refreshToken: result.refresh_token, - idToken: result.id_token - }; + public async refreshAccessToken(refreshToken: string): Promise { + const body = new URLSearchParams(); + body.set("grant_type", "refresh_token"); + body.set("refresh_token", refreshToken); + const request = createOAuth2Request(this.tokenEndpoint, body); + const encodedCredentials = encodeBasicCredentials(this.clientId, this.clientSecret); + request.headers.set("Authorization", `Basic ${encodedCredentials}`); + const tokens = await sendTokenRequest(request); return tokens; } -} -interface TokenResponseBody { - access_token: string; - refresh_token: string; - id_token: string; -} - -export interface Auth0Tokens { - accessToken: string; - refreshToken: string; - idToken: string; + public async revokeToken(token: string): Promise { + const body = new URLSearchParams(); + body.set("token", token); + const request = createOAuth2Request(this.tokenRevocationEndpoint, body); + const encodedCredentials = encodeBasicCredentials(this.clientId, this.clientSecret); + request.headers.set("Authorization", `Basic ${encodedCredentials}`); + await sendTokenRevocationRequest(request); + } } diff --git a/src/providers/authentik.ts b/src/providers/authentik.ts index 31893656..584a9db5 100644 --- a/src/providers/authentik.ts +++ b/src/providers/authentik.ts @@ -1,76 +1,77 @@ -import { OAuth2Client } from "oslo/oauth2"; -import { TimeSpan, createDate } from "oslo"; -import type { OAuth2ProviderWithPKCE } from "../index.js"; +import { createS256CodeChallenge } from "../oauth2.js"; +import { + createOAuth2Request, + encodeBasicCredentials, + sendTokenRequest, + sendTokenRevocationRequest +} from "../request.js"; -export class Authentik implements OAuth2ProviderWithPKCE { - private client: OAuth2Client; +import type { OAuth2Tokens } from "../oauth2.js"; + +export class Authentik { + private authorizationEndpoint: string; + private tokenEndpoint: string; + private tokenRevocationEndpoint: string; + + private clientId: string; private clientSecret: string; + private redirectURI: string; - constructor(realmURL: string, clientId: string, clientSecret: string, redirectURI: string) { - const authorizeEndpoint = realmURL + "/application/o/authorize/"; - const tokenEndpoint = realmURL + "/application/o/token/"; - this.client = new OAuth2Client(clientId, authorizeEndpoint, tokenEndpoint, { - redirectURI - }); + constructor(domain: string, clientId: string, clientSecret: string, redirectURI: string) { + this.authorizationEndpoint = `https://${domain}/application/o/authorize/`; + this.tokenEndpoint = `https://${domain}/application/o/token/`; + this.tokenRevocationEndpoint = `https://${domain}/application/o/revoke`; + this.clientId = clientId; this.clientSecret = clientSecret; + this.redirectURI = redirectURI; } - public async createAuthorizationURL( - state: string, - codeVerifier: string, - options?: { - scopes?: string[]; - } - ): Promise { - const scopes = options?.scopes ?? []; - return await this.client.createAuthorizationURL({ - state, - codeVerifier, - scopes: [...scopes, "openid"] - }); + public createAuthorizationURL(state: string, codeVerifier: string, scopes: string[]): URL { + const url = new URL(this.authorizationEndpoint); + url.searchParams.set("response_type", "code"); + url.searchParams.set("client_id", this.clientId); + url.searchParams.set("state", state); + url.searchParams.set("scope", scopes.join(" ")); + url.searchParams.set("redirect_uri", this.redirectURI); + const codeChallenge = createS256CodeChallenge(codeVerifier); + url.searchParams.set("code_challenge_method", "S256"); + url.searchParams.set("code_challenge", codeChallenge); + return url; } public async validateAuthorizationCode( code: string, codeVerifier: string - ): Promise { - const result = await this.client.validateAuthorizationCode(code, { - codeVerifier, - credentials: this.clientSecret - }); - const tokens: AuthentikTokens = { - accessToken: result.access_token, - accessTokenExpiresAt: createDate(new TimeSpan(result.expires_in, "s")), - refreshToken: result.refresh_token ?? null, - idToken: result.id_token - }; + ): Promise { + const body = new URLSearchParams(); + body.set("grant_type", "authorization_code"); + body.set("code", code); + body.set("code_verifier", codeVerifier); + body.set("redirect_uri", this.redirectURI); + const request = createOAuth2Request(this.tokenEndpoint, body); + const encodedCredentials = encodeBasicCredentials(this.clientId, this.clientSecret); + request.headers.set("Authorization", `Basic ${encodedCredentials}`); + const tokens = await sendTokenRequest(request); return tokens; } - public async refreshAccessToken(refreshToken: string): Promise { - const result = await this.client.refreshAccessToken(refreshToken, { - credentials: this.clientSecret - }); - const tokens: AuthentikTokens = { - accessToken: result.access_token, - accessTokenExpiresAt: createDate(new TimeSpan(result.expires_in, "s")), - refreshToken: result.refresh_token ?? null, - idToken: result.id_token - }; + public async refreshAccessToken(refreshToken: string): Promise { + const body = new URLSearchParams(); + body.set("grant_type", "refresh_token"); + body.set("refresh_token", refreshToken); + const request = createOAuth2Request(this.tokenEndpoint, body); + const encodedCredentials = encodeBasicCredentials(this.clientId, this.clientSecret); + request.headers.set("Authorization", `Basic ${encodedCredentials}`); + const tokens = await sendTokenRequest(request); return tokens; } -} -interface TokenResponseBody { - access_token: string; - expires_in: number; - refresh_token?: string; - id_token: string; -} - -export interface AuthentikTokens { - accessToken: string; - accessTokenExpiresAt: Date; - refreshToken: string | null; - idToken: string; + public async revokeToken(token: string): Promise { + const body = new URLSearchParams(); + body.set("token", token); + const request = createOAuth2Request(this.tokenRevocationEndpoint, body); + const encodedCredentials = encodeBasicCredentials(this.clientId, this.clientSecret); + request.headers.set("Authorization", `Basic ${encodedCredentials}`); + await sendTokenRevocationRequest(request); + } } diff --git a/src/providers/bitbucket.ts b/src/providers/bitbucket.ts index bee1bd5b..f42d0874 100644 --- a/src/providers/bitbucket.ts +++ b/src/providers/bitbucket.ts @@ -1,67 +1,50 @@ -import { OAuth2Client } from "oslo/oauth2"; -import { TimeSpan, createDate } from "oslo"; +import { createOAuth2Request, encodeBasicCredentials, sendTokenRequest } from "../request.js"; -import type { OAuth2Provider } from "../index.js"; +import type { OAuth2Tokens } from "../oauth2.js"; -const authorizeEndpoint = "https://bitbucket.org/site/oauth2/authorize"; +const authorizationEndpoint = "https://bitbucket.org/site/oauth2/authorize"; const tokenEndpoint = "https://bitbucket.org/site/oauth2/access_token"; -export class Bitbucket implements OAuth2Provider { - private client: OAuth2Client; +export class Bitbucket { + private clientId: string; private clientSecret: string; + private redirectURI: string; constructor(clientId: string, clientSecret: string, redirectURI: string) { - this.client = new OAuth2Client(clientId, authorizeEndpoint, tokenEndpoint, { - redirectURI - }); + this.clientId = clientId; this.clientSecret = clientSecret; + this.redirectURI = redirectURI; } - public async createAuthorizationURL( - state: string, - options?: { - scopes?: string[]; - } - ): Promise { - return await this.client.createAuthorizationURL({ - state, - scopes: options?.scopes ?? [] - }); + public createAuthorizationURL(state: string): URL { + const url = new URL(authorizationEndpoint); + url.searchParams.set("response_type", "code"); + url.searchParams.set("client_id", this.clientId); + url.searchParams.set("state", state); + url.searchParams.set("redirect_uri", this.redirectURI); + return url; } - public async validateAuthorizationCode(code: string): Promise { - const result = await this.client.validateAuthorizationCode(code, { - credentials: this.clientSecret - }); - const tokens: BitbucketTokens = { - accessToken: result.access_token, - accessTokenExpiresAt: createDate(new TimeSpan(result.expires_in, "s")), - refreshToken: result.refresh_token - }; + public async validateAuthorizationCode(code: string): Promise { + const body = new URLSearchParams(); + body.set("grant_type", "authorization_code"); + body.set("code", code); + body.set("redirect_uri", this.redirectURI); + const request = createOAuth2Request(tokenEndpoint, body); + const encodedCredentials = encodeBasicCredentials(this.clientId, this.clientSecret); + request.headers.set("Authorization", `Basic ${encodedCredentials}`); + const tokens = await sendTokenRequest(request); return tokens; } - public async refreshAccessToken(refreshToken: string): Promise { - const result = await this.client.refreshAccessToken(refreshToken, { - credentials: this.clientSecret - }); - const tokens: BitbucketTokens = { - accessToken: result.access_token, - accessTokenExpiresAt: createDate(new TimeSpan(result.expires_in, "s")), - refreshToken: result.refresh_token - }; + public async refreshAccessToken(refreshToken: string): Promise { + const body = new URLSearchParams(); + body.set("grant_type", "refresh_token"); + body.set("refresh_token", refreshToken); + const request = createOAuth2Request(tokenEndpoint, body); + const encodedCredentials = encodeBasicCredentials(this.clientId, this.clientSecret); + request.headers.set("Authorization", `Basic ${encodedCredentials}`); + const tokens = await sendTokenRequest(request); return tokens; } } - -interface TokenResponseBody { - access_token: string; - expires_in: number; - refresh_token: string; -} - -export interface BitbucketTokens { - accessToken: string; - accessTokenExpiresAt: Date; - refreshToken: string; -} diff --git a/src/providers/box.ts b/src/providers/box.ts index b0bebdb0..bd79c720 100644 --- a/src/providers/box.ts +++ b/src/providers/box.ts @@ -1,39 +1,50 @@ -import { OAuth2Client } from "oslo/oauth2"; +import { createOAuth2Request, sendTokenRequest, sendTokenRevocationRequest } from "../request.js"; -import type { OAuth2Provider } from "../index.js"; +import type { OAuth2Tokens } from "../oauth2.js"; -const authorizeEndpoint = "https://account.box.com/api/oauth2/authorize"; +const authorizationEndpoint = "https://account.box.com/api/oauth2/authorize"; const tokenEndpoint = "https://api.box.com/oauth2/token"; +const tokenRevocationEndpoint = "https://api.box.com/oauth2/revoke"; -export class Box implements OAuth2Provider { - private client: OAuth2Client; +export class Box { + private clientId: string; private clientSecret: string; + private redirectURI: string; constructor(clientId: string, clientSecret: string, redirectURI: string) { - this.client = new OAuth2Client(clientId, authorizeEndpoint, tokenEndpoint, { - redirectURI - }); + this.clientId = clientId; this.clientSecret = clientSecret; + this.redirectURI = redirectURI; } - public async createAuthorizationURL(state: string): Promise { - return await this.client.createAuthorizationURL({ - state - }); + public createAuthorizationURL(state: string, scopes: string[]): URL { + const url = new URL(authorizationEndpoint); + url.searchParams.set("response_type", "code"); + url.searchParams.set("client_id", this.clientId); + url.searchParams.set("state", state); + url.searchParams.set("scope", scopes.join(" ")); + url.searchParams.set("redirect_uri", this.redirectURI); + return url; } - public async validateAuthorizationCode(code: string): Promise { - const result = await this.client.validateAuthorizationCode(code, { - authenticateWith: "request_body", - credentials: this.clientSecret - }); - const tokens: BoxTokens = { - accessToken: result.access_token - }; + public async validateAuthorizationCode(code: string): Promise { + const body = new URLSearchParams(); + body.set("grant_type", "authorization_code"); + body.set("code", code); + body.set("redirect_uri", this.redirectURI); + body.set("client_id", this.clientId); + body.set("client_secret", this.clientSecret); + const request = createOAuth2Request(tokenEndpoint, body); + const tokens = await sendTokenRequest(request); return tokens; } -} -export interface BoxTokens { - accessToken: string; + public async revokeToken(token: string): Promise { + const body = new URLSearchParams(); + body.set("token", token); + body.set("client_id", this.clientId); + body.set("client_secret", this.clientSecret); + const request = createOAuth2Request(tokenRevocationEndpoint, body); + await sendTokenRevocationRequest(request); + } } diff --git a/src/providers/coinbase.ts b/src/providers/coinbase.ts index 0cfc73b5..b588f0e4 100644 --- a/src/providers/coinbase.ts +++ b/src/providers/coinbase.ts @@ -1,75 +1,61 @@ -import { OAuth2Client } from "oslo/oauth2"; -import { TimeSpan, createDate } from "oslo"; +import { createOAuth2Request, sendTokenRequest, sendTokenRevocationRequest } from "../request.js"; -import type { OAuth2Provider } from "../index.js"; +import type { OAuth2Tokens } from "../oauth2.js"; -const authorizeEndpoint = "https://www.coinbase.com/oauth/authorize"; +const authorizationEndpoint = "https://www.coinbase.com/oauth/authorize"; const tokenEndpoint = "https://www.coinbase.com/oauth/token"; +const tokenRevocationEndpoint = "https://api.coinbase.com/oauth/revoke"; -export class Coinbase implements OAuth2Provider { - private client: OAuth2Client; +export class Coinbase { + private clientId: string; private clientSecret: string; + private redirectURI: string; - constructor( - clientId: string, - clientSecret: string, - options?: { - redirectURI?: string; - } - ) { - this.client = new OAuth2Client(clientId, authorizeEndpoint, tokenEndpoint, { - redirectURI: options?.redirectURI - }); + constructor(clientId: string, clientSecret: string, redirectURI: string) { + this.clientId = clientId; this.clientSecret = clientSecret; + this.redirectURI = redirectURI; } - public async createAuthorizationURL( - state: string, - options?: { - scopes?: string[]; - } - ): Promise { - return await this.client.createAuthorizationURL({ - state, - scopes: options?.scopes ?? [] - }); + public createAuthorizationURL(state: string, scopes: string[]): URL { + const url = new URL(authorizationEndpoint); + url.searchParams.set("response_type", "code"); + url.searchParams.set("client_id", this.clientId); + url.searchParams.set("state", state); + url.searchParams.set("scope", scopes.join(" ")); + url.searchParams.set("redirect_uri", this.redirectURI); + return url; } - public async validateAuthorizationCode(code: string): Promise { - const result = await this.client.validateAuthorizationCode(code, { - authenticateWith: "request_body", - credentials: this.clientSecret - }); - const tokens: CoinbaseTokens = { - accessToken: result.access_token, - refreshToken: result.refresh_token, - accessTokenExpiresAt: createDate(new TimeSpan(result.expires_in, "s")) - }; + public async validateAuthorizationCode(code: string): Promise { + const body = new URLSearchParams(); + body.set("grant_type", "authorization_code"); + body.set("code", code); + body.set("redirect_uri", this.redirectURI); + body.set("client_id", this.clientId); + body.set("client_secret", this.clientSecret); + const request = createOAuth2Request(tokenEndpoint, body); + const tokens = await sendTokenRequest(request); return tokens; } - public async refreshAccessToken(refreshToken: string): Promise { - const result = await this.client.refreshAccessToken(refreshToken, { - authenticateWith: "request_body", - credentials: this.clientSecret - }); - const tokens: CoinbaseTokens = { - accessToken: result.access_token, - refreshToken: result.refresh_token, - accessTokenExpiresAt: createDate(new TimeSpan(result.expires_in, "s")) - }; + public async refreshAccessToken(refreshToken: string): Promise { + const body = new URLSearchParams(); + body.set("grant_type", "refresh_token"); + body.set("refresh_token", refreshToken); + body.set("client_id", this.clientId); + body.set("client_secret", this.clientSecret); + const request = createOAuth2Request(tokenEndpoint, body); + const tokens = await sendTokenRequest(request); return tokens; } -} - -interface TokenResponseBody { - access_token: string; - expires_in: number; - refresh_token: string; -} -export interface CoinbaseTokens { - accessToken: string; - refreshToken: string; - accessTokenExpiresAt: Date; + public async revokeToken(token: string): Promise { + const body = new URLSearchParams(); + body.set("token", token); + body.set("client_id", this.clientId); + body.set("client_secret", this.clientSecret); + const request = createOAuth2Request(tokenRevocationEndpoint, body); + await sendTokenRevocationRequest(request); + } } diff --git a/src/providers/discord.ts b/src/providers/discord.ts index 6cdf216b..2411b77c 100644 --- a/src/providers/discord.ts +++ b/src/providers/discord.ts @@ -1,67 +1,66 @@ -import { OAuth2Client } from "oslo/oauth2"; -import { TimeSpan, createDate } from "oslo"; +import { + createOAuth2Request, + encodeBasicCredentials, + sendTokenRequest, + sendTokenRevocationRequest +} from "../request.js"; -import type { OAuth2Provider } from "../index.js"; +import type { OAuth2Tokens } from "../oauth2.js"; -const authorizeEndpoint = "https://discord.com/oauth2/authorize"; +const authorizationEndpoint = "https://discord.com/oauth2/authorize"; const tokenEndpoint = "https://discord.com/api/oauth2/token"; +const tokenRevocationEndpoint = "https://discord.com/api/oauth2/token/revoke"; -export class Discord implements OAuth2Provider { - private client: OAuth2Client; +export class Discord { + private clientId: string; private clientSecret: string; + private redirectURI: string; constructor(clientId: string, clientSecret: string, redirectURI: string) { - this.client = new OAuth2Client(clientId, authorizeEndpoint, tokenEndpoint, { - redirectURI - }); + this.clientId = clientId; this.clientSecret = clientSecret; + this.redirectURI = redirectURI; } - public async createAuthorizationURL( - state: string, - options?: { - scopes?: string[]; - } - ): Promise { - return await this.client.createAuthorizationURL({ - state, - scopes: options?.scopes ?? [] - }); + public createAuthorizationURL(state: string, scopes: string[]): URL { + const url = new URL(authorizationEndpoint); + url.searchParams.set("response_type", "code"); + url.searchParams.set("client_id", this.clientId); + url.searchParams.set("state", state); + url.searchParams.set("scope", scopes.join(" ")); + url.searchParams.set("redirect_uri", this.redirectURI); + return url; } - public async validateAuthorizationCode(code: string): Promise { - const result = await this.client.validateAuthorizationCode(code, { - credentials: this.clientSecret - }); - const tokens: DiscordTokens = { - accessToken: result.access_token, - refreshToken: result.refresh_token, - accessTokenExpiresAt: createDate(new TimeSpan(result.expires_in, "s")) - }; + public async validateAuthorizationCode(code: string): Promise { + const body = new URLSearchParams(); + body.set("grant_type", "authorization_code"); + body.set("code", code); + body.set("redirect_uri", this.redirectURI); + const request = createOAuth2Request(tokenEndpoint, body); + const encodedCredentials = encodeBasicCredentials(this.clientId, this.clientSecret); + request.headers.set("Authorization", `Basic ${encodedCredentials}`); + const tokens = await sendTokenRequest(request); return tokens; } - public async refreshAccessToken(refreshToken: string): Promise { - const result = await this.client.refreshAccessToken(refreshToken, { - credentials: this.clientSecret - }); - const tokens: DiscordTokens = { - accessToken: result.access_token, - refreshToken: result.refresh_token, - accessTokenExpiresAt: createDate(new TimeSpan(result.expires_in, "s")) - }; + public async refreshAccessToken(refreshToken: string): Promise { + const body = new URLSearchParams(); + body.set("grant_type", "refresh_token"); + body.set("refresh_token", refreshToken); + const request = createOAuth2Request(tokenEndpoint, body); + const encodedCredentials = encodeBasicCredentials(this.clientId, this.clientSecret); + request.headers.set("Authorization", `Basic ${encodedCredentials}`); + const tokens = await sendTokenRequest(request); return tokens; } -} - -interface TokenResponseBody { - access_token: string; - expires_in: number; - refresh_token: string; -} -export interface DiscordTokens { - accessToken: string; - refreshToken: string; - accessTokenExpiresAt: Date; + public async revokeToken(token: string): Promise { + const body = new URLSearchParams(); + body.set("token", token); + const request = createOAuth2Request(tokenRevocationEndpoint, body); + const encodedCredentials = encodeBasicCredentials(this.clientId, this.clientSecret); + request.headers.set("Authorization", `Basic ${encodedCredentials}`); + await sendTokenRevocationRequest(request); + } } diff --git a/src/providers/dribbble.ts b/src/providers/dribbble.ts index 07140734..c6e980e9 100644 --- a/src/providers/dribbble.ts +++ b/src/providers/dribbble.ts @@ -1,45 +1,40 @@ -import { OAuth2Client } from "oslo/oauth2"; +import { createOAuth2Request, sendTokenRequest } from "../request.js"; -import type { OAuth2Provider } from "../index.js"; +import type { OAuth2Tokens } from "../oauth2.js"; -const authorizeEndpoint = "https://dribbble.com/oauth/authorize"; +const authorizationEndpoint = "https://dribbble.com/oauth/authorize"; const tokenEndpoint = "https://dribbble.com/oauth/token"; -export class Dribbble implements OAuth2Provider { - private client: OAuth2Client; +export class Dribbble { + private clientId: string; private clientSecret: string; + private redirectURI: string; constructor(clientId: string, clientSecret: string, redirectURI: string) { - this.client = new OAuth2Client(clientId, authorizeEndpoint, tokenEndpoint, { - redirectURI - }); + this.clientId = clientId; this.clientSecret = clientSecret; + this.redirectURI = redirectURI; } - public async createAuthorizationURL( - state: string, - options?: { - scopes?: string[]; - } - ): Promise { - return await this.client.createAuthorizationURL({ - state, - scopes: options?.scopes ?? [] - }); + public createAuthorizationURL(state: string, scopes: string[]): URL { + const url = new URL(authorizationEndpoint); + url.searchParams.set("response_type", "code"); + url.searchParams.set("client_id", this.clientId); + url.searchParams.set("state", state); + url.searchParams.set("scope", scopes.join(" ")); + url.searchParams.set("redirect_uri", this.redirectURI); + return url; } - public async validateAuthorizationCode(code: string): Promise { - const result = await this.client.validateAuthorizationCode(code, { - authenticateWith: "request_body", - credentials: this.clientSecret - }); - const tokens: DribbbleTokens = { - accessToken: result.access_token - }; + public async validateAuthorizationCode(code: string): Promise { + const body = new URLSearchParams(); + body.set("grant_type", "authorization_code"); + body.set("code", code); + body.set("redirect_uri", this.redirectURI); + body.set("client_id", this.clientId); + body.set("client_secret", this.clientSecret); + const request = createOAuth2Request(tokenEndpoint, body); + const tokens = await sendTokenRequest(request); return tokens; } } - -export interface DribbbleTokens { - accessToken: string; -} diff --git a/src/providers/dropbox.ts b/src/providers/dropbox.ts index 348e082f..bcf65974 100644 --- a/src/providers/dropbox.ts +++ b/src/providers/dropbox.ts @@ -1,83 +1,67 @@ -import { OAuth2Client } from "oslo/oauth2"; -import { TimeSpan, createDate } from "oslo"; +import { + createOAuth2Request, + encodeBasicCredentials, + sendTokenRequest, + sendTokenRevocationRequest +} from "../request.js"; -import type { OAuth2Provider } from "../index.js"; +import type { OAuth2Tokens } from "../oauth2.js"; -const authorizeEndpoint = "https://www.dropbox.com/oauth2/authorize"; +const authorizationEndpoint = "https://www.dropbox.com/oauth2/authorize"; const tokenEndpoint = "https://api.dropboxapi.com/oauth2/token"; +const tokenRevocationEndpoint = "https://api.dropboxapi.com/2/auth/token/revoke"; -export class Dropbox implements OAuth2Provider { - private client: OAuth2Client; +export class Dropbox { + private clientId: string; private clientSecret: string; + private redirectURI: string; constructor(clientId: string, clientSecret: string, redirectURI: string) { - this.client = new OAuth2Client(clientId, authorizeEndpoint, tokenEndpoint, { - redirectURI - }); + this.clientId = clientId; this.clientSecret = clientSecret; + this.redirectURI = redirectURI; } - public async createAuthorizationURL( - state: string, - options?: { - scopes?: string[]; - } - ): Promise { - const scopes = options?.scopes ?? []; - return await this.client.createAuthorizationURL({ - state, - scopes: [...scopes, "openid"] - }); + public createAuthorizationURL(state: string, scopes: string[]): URL { + const url = new URL(authorizationEndpoint); + url.searchParams.set("response_type", "code"); + url.searchParams.set("client_id", this.clientId); + url.searchParams.set("state", state); + url.searchParams.set("scope", scopes.join(" ")); + url.searchParams.set("redirect_uri", this.redirectURI); + return url; } - public async validateAuthorizationCode(code: string): Promise { - const result = await this.client.validateAuthorizationCode( - code, - { - credentials: this.clientSecret - } - ); - const tokens: DropboxTokens = { - accessToken: result.access_token, - accessTokenExpiresAt: createDate(new TimeSpan(result.expires_in, "s")), - refreshToken: result.refresh_token ?? null, - idToken: result.id_token - }; + public async validateAuthorizationCode(code: string): Promise { + const body = new URLSearchParams(); + body.set("grant_type", "authorization_code"); + body.set("code", code); + body.set("redirect_uri", this.redirectURI); + const request = createOAuth2Request(tokenEndpoint, body); + const encodedCredentials = encodeBasicCredentials(this.clientId, this.clientSecret); + request.headers.set("Authorization", `Basic ${encodedCredentials}`); + const tokens = await sendTokenRequest(request); return tokens; } - public async refreshAccessToken(refreshToken: string): Promise { - const result = await this.client.refreshAccessToken(refreshToken, { - credentials: this.clientSecret - }); - const tokens: DropboxRefreshedTokens = { - accessToken: result.access_token, - accessTokenExpiresAt: createDate(new TimeSpan(result.expires_in, "s")) - }; + public async refreshAccessToken(refreshToken: string): Promise { + const body = new URLSearchParams(); + body.set("grant_type", "refresh_token"); + body.set("refresh_token", refreshToken); + const request = createOAuth2Request(tokenEndpoint, body); + const encodedCredentials = encodeBasicCredentials(this.clientId, this.clientSecret); + request.headers.set("Authorization", `Basic ${encodedCredentials}`); + const tokens = await sendTokenRequest(request); return tokens; } -} - -interface AuthorizationCodeResponseBody { - access_token: string; - expires_in: number; - refresh_token?: string; - id_token: string; -} - -interface RefreshTokenResponseBody { - access_token: string; - expires_in: number; -} - -export interface DropboxTokens { - accessToken: string; - accessTokenExpiresAt: Date; - refreshToken: string | null; - idToken: string; -} -export interface DropboxRefreshedTokens { - accessToken: string; - accessTokenExpiresAt: Date; + // Revokes both access and refresh token + public async revokeToken(token: string): Promise { + const body = new URLSearchParams(); + body.set("token", token); + const request = createOAuth2Request(tokenRevocationEndpoint, body); + const encodedCredentials = encodeBasicCredentials(this.clientId, this.clientSecret); + request.headers.set("Authorization", `Basic ${encodedCredentials}`); + await sendTokenRevocationRequest(request); + } } diff --git a/src/providers/facebook.ts b/src/providers/facebook.ts index c31141c2..3ef75c05 100644 --- a/src/providers/facebook.ts +++ b/src/providers/facebook.ts @@ -1,53 +1,40 @@ -import { OAuth2Client } from "oslo/oauth2"; -import { TimeSpan, createDate } from "oslo"; +import { createOAuth2Request, sendTokenRequest } from "../request.js"; -import type { OAuth2Provider } from "../index.js"; +import type { OAuth2Tokens } from "../oauth2.js"; -const authorizeEndpoint = "https://www.facebook.com/v16.0/dialog/oauth"; +const authorizationEndpoint = "https://www.facebook.com/v16.0/dialog/oauth"; const tokenEndpoint = "https://graph.facebook.com/v16.0/oauth/access_token"; -export class Facebook implements OAuth2Provider { - private client: OAuth2Client; +export class Facebook { + private clientId: string; private clientSecret: string; + private redirectURI: string; constructor(clientId: string, clientSecret: string, redirectURI: string) { - this.client = new OAuth2Client(clientId, authorizeEndpoint, tokenEndpoint, { - redirectURI - }); + this.clientId = clientId; this.clientSecret = clientSecret; + this.redirectURI = redirectURI; } - public async createAuthorizationURL( - state: string, - options?: { - scopes?: string[]; - } - ): Promise { - return await this.client.createAuthorizationURL({ - state, - scopes: options?.scopes ?? [] - }); + public createAuthorizationURL(state: string, scopes: string[]): URL { + const url = new URL(authorizationEndpoint); + url.searchParams.set("response_type", "code"); + url.searchParams.set("client_id", this.clientId); + url.searchParams.set("state", state); + url.searchParams.set("scope", scopes.join(" ")); + url.searchParams.set("redirect_uri", this.redirectURI); + return url; } - public async validateAuthorizationCode(code: string): Promise { - const result = await this.client.validateAuthorizationCode(code, { - authenticateWith: "request_body", - credentials: this.clientSecret - }); - const tokens: FacebookTokens = { - accessToken: result.access_token, - accessTokenExpiresAt: createDate(new TimeSpan(result.expires_in, "s")) - }; + public async validateAuthorizationCode(code: string): Promise { + const body = new URLSearchParams(); + body.set("grant_type", "authorization_code"); + body.set("code", code); + body.set("redirect_uri", this.redirectURI); + body.set("client_id", this.clientId); + body.set("client_secret", this.clientSecret); + const request = createOAuth2Request(tokenEndpoint, body); + const tokens = await sendTokenRequest(request); return tokens; } } - -interface TokenResponseBody { - access_token: string; - expires_in: number; -} - -export interface FacebookTokens { - accessToken: string; - accessTokenExpiresAt: Date; -} diff --git a/src/providers/figma.ts b/src/providers/figma.ts index 800d3a34..50f1c81a 100644 --- a/src/providers/figma.ts +++ b/src/providers/figma.ts @@ -1,84 +1,51 @@ -import { TimeSpan, createDate } from "oslo"; -import { OAuth2Client } from "oslo/oauth2"; +import { createOAuth2Request, sendTokenRequest } from "../request.js"; -import type { OAuth2Provider } from "../index.js"; +import type { OAuth2Tokens } from "../oauth2.js"; -const authorizeEndpoint = "https://www.figma.com/oauth"; +const authorizationEndpoint = "https://www.figma.com/oauth"; const tokenEndpoint = "https://www.figma.com/api/oauth/token"; -export class Figma implements OAuth2Provider { - private client: OAuth2Client; +export class Figma { + private clientId: string; private clientSecret: string; + private redirectURI: string; constructor(clientId: string, clientSecret: string, redirectURI: string) { - this.client = new OAuth2Client(clientId, authorizeEndpoint, tokenEndpoint, { - redirectURI - }); + this.clientId = clientId; this.clientSecret = clientSecret; + this.redirectURI = redirectURI; } - public async createAuthorizationURL( - state: string, - options?: { - scopes?: string[]; - } - ): Promise { - return await this.client.createAuthorizationURL({ - state, - scopes: options?.scopes ?? [] - }); + public createAuthorizationURL(state: string, scopes: string[]): URL { + const url = new URL(authorizationEndpoint); + url.searchParams.set("response_type", "code"); + url.searchParams.set("client_id", this.clientId); + url.searchParams.set("state", state); + url.searchParams.set("scope", scopes.join(" ")); + url.searchParams.set("redirect_uri", this.redirectURI); + return url; } - public async validateAuthorizationCode(code: string): Promise { - const result = await this.client.validateAuthorizationCode( - code, - { - authenticateWith: "request_body", - credentials: this.clientSecret - } - ); - const tokens: FigmaTokens = { - accessToken: result.access_token, - refreshToken: result.refresh_token ?? null, - accessTokenExpiresAt: createDate(new TimeSpan(result.expires_in, "s")), - userId: result.user_id - }; + public async validateAuthorizationCode(code: string): Promise { + const body = new URLSearchParams(); + body.set("grant_type", "authorization_code"); + body.set("code", code); + body.set("redirect_uri", this.redirectURI); + body.set("client_id", this.clientId); + body.set("client_secret", this.clientSecret); + const request = createOAuth2Request(tokenEndpoint, body); + const tokens = await sendTokenRequest(request); return tokens; } - public async refreshAccessToken(refreshToken: string): Promise { - const result = await this.client.refreshAccessToken(refreshToken, { - authenticateWith: "request_body", - credentials: this.clientSecret - }); - const tokens: FigmaRefreshedTokens = { - accessToken: result.access_token, - accessTokenExpiresAt: createDate(new TimeSpan(result.expires_in, "s")) - }; + public async refreshAccessToken(refreshToken: string): Promise { + const body = new URLSearchParams(); + body.set("grant_type", "refresh_token"); + body.set("refresh_token", refreshToken); + body.set("client_id", this.clientId); + body.set("client_secret", this.clientSecret); + const request = createOAuth2Request(tokenEndpoint, body); + const tokens = await sendTokenRequest(request); return tokens; } } - -interface AuthorizationCodeResponseBody { - access_token: string; - refresh_token?: string; - expires_in: number; - user_id: string; -} - -interface RefreshTokenResponseBody { - access_token: string; - expires_in: number; -} - -export interface FigmaTokens { - userId: string; - accessToken: string; - refreshToken: string | null; - accessTokenExpiresAt: Date; -} - -export interface FigmaRefreshedTokens { - accessToken: string; - accessTokenExpiresAt: Date; -} diff --git a/src/providers/github.ts b/src/providers/github.ts index 976137c5..a3b986ad 100644 --- a/src/providers/github.ts +++ b/src/providers/github.ts @@ -1,55 +1,55 @@ -import { OAuth2Client } from "oslo/oauth2"; +import { createOAuth2Request, encodeBasicCredentials, sendTokenRequest } from "../request.js"; -import type { OAuth2Provider } from "../index.js"; +import type { OAuth2Tokens } from "../oauth2.js"; -export class GitHub implements OAuth2Provider { - private client: OAuth2Client; - private clientSecret: string; - - constructor( - clientId: string, - clientSecret: string, - options?: { - redirectURI?: string; - enterpriseDomain?: string; - } - ) { - const baseUrl = options?.enterpriseDomain ?? "https://github.com"; +const authorizationEndpoint = "https://github.com/login/oauth/authorize"; +const tokenEndpoint = "https://github.com/login/oauth/access_token"; - const authorizeEndpoint = baseUrl + "/login/oauth/authorize"; - const tokenEndpoint = baseUrl + "/login/oauth/access_token"; +export class GitHub { + private clientId: string; + private clientSecret: string; + private redirectURI: string | null; - this.client = new OAuth2Client(clientId, authorizeEndpoint, tokenEndpoint, { - redirectURI: options?.redirectURI - }); + constructor(clientId: string, clientSecret: string, redirectURI: string | null) { + this.clientId = clientId; this.clientSecret = clientSecret; + this.redirectURI = redirectURI; } - public async createAuthorizationURL( - state: string, - options?: { - scopes?: string[]; + public createAuthorizationURL(state: string, scopes: string[]): URL { + const url = new URL(authorizationEndpoint); + url.searchParams.set("response_type", "code"); + url.searchParams.set("client_id", this.clientId); + url.searchParams.set("state", state); + url.searchParams.set("scope", scopes.join(" ")); + if (this.redirectURI !== null) { + url.searchParams.set("redirect_uri", this.redirectURI); } - ): Promise { - return await this.client.createAuthorizationURL({ - state, - scopes: options?.scopes ?? [] - }); + return url; } - public async validateAuthorizationCode(code: string): Promise { - const result = await this.client.validateAuthorizationCode(code, { - authenticateWith: "request_body", - credentials: this.clientSecret - }); - const tokens: GitHubTokens = { - accessToken: result.access_token - }; + public async validateAuthorizationCode(code: string): Promise { + const body = new URLSearchParams(); + body.set("grant_type", "authorization_code"); + body.set("code", code); + if (this.redirectURI !== null) { + body.set("redirect_uri", this.redirectURI); + } + const request = createOAuth2Request(tokenEndpoint, body); + const encodedCredentials = encodeBasicCredentials(this.clientId, this.clientSecret); + request.headers.set("Authorization", `Basic ${encodedCredentials}`); + const tokens = await sendTokenRequest(request); return tokens; } -} -export interface GitHubTokens { - accessToken: string; + public async refreshAccessToken(refreshToken: string): Promise { + const body = new URLSearchParams(); + body.set("grant_type", "refresh_token"); + body.set("refresh_token", refreshToken); + const request = createOAuth2Request(tokenEndpoint, body); + const encodedCredentials = encodeBasicCredentials(this.clientId, this.clientSecret); + request.headers.set("Authorization", `Basic ${encodedCredentials}`); + const tokens = await sendTokenRequest(request); + return tokens; + } } - diff --git a/src/providers/gitlab.ts b/src/providers/gitlab.ts index 3e75a3be..2f31d5c3 100644 --- a/src/providers/gitlab.ts +++ b/src/providers/gitlab.ts @@ -1,76 +1,69 @@ -import { OAuth2Client } from "oslo/oauth2"; -import { TimeSpan, createDate } from "oslo"; +import { + createOAuth2Request, + encodeBasicCredentials, + sendTokenRequest, + sendTokenRevocationRequest +} from "../request.js"; -import type { OAuth2Provider } from "../index.js"; +import type { OAuth2Tokens } from "../oauth2.js"; -export class GitLab implements OAuth2Provider { - private client: OAuth2Client; +export class GitLab { + private authorizationEndpoint: string; + private tokenEndpoint: string; + private tokenRevocationEndpoint: string; + + private clientId: string; private clientSecret: string; + private redirectURI: string; - constructor( - clientId: string, - clientSecret: string, - redirectURI: string, - options?: { - domain?: string; - } - ) { - const domain = options?.domain ?? "https://gitlab.com"; - const authorizeEndpoint = domain + "/oauth/authorize"; - const tokenEndpoint = domain + "/oauth/token"; - this.client = new OAuth2Client(clientId, authorizeEndpoint, tokenEndpoint, { - redirectURI - }); + constructor(domain: string, clientId: string, clientSecret: string, redirectURI: string) { + this.authorizationEndpoint = `https://${domain}/oauth/authorize`; + this.tokenEndpoint = domain + `https://${domain}/oauth/token`; + this.tokenRevocationEndpoint = domain + "/oauth/revoke"; + this.clientId = clientId; this.clientSecret = clientSecret; + this.redirectURI = redirectURI; } - public async createAuthorizationURL( - state: string, - options?: { - scopes?: string[]; - } - ): Promise { - return await this.client.createAuthorizationURL({ - state, - scopes: options?.scopes ?? [] - }); + public createAuthorizationURL(state: string, scopes: string[]): URL { + const url = new URL(this.authorizationEndpoint); + url.searchParams.set("response_type", "code"); + url.searchParams.set("client_id", this.clientId); + url.searchParams.set("state", state); + url.searchParams.set("scope", scopes.join(" ")); + url.searchParams.set("redirect_uri", this.redirectURI); + return url; } - public async validateAuthorizationCode(code: string): Promise { - const result = await this.client.validateAuthorizationCode(code, { - authenticateWith: "request_body", - credentials: this.clientSecret - }); - const tokens: GitLabTokens = { - accessToken: result.access_token, - accessTokenExpiresAt: createDate(new TimeSpan(result.expires_in, "s")), - refreshToken: result.refresh_token - }; + public async validateAuthorizationCode(code: string): Promise { + const body = new URLSearchParams(); + body.set("grant_type", "authorization_code"); + body.set("code", code); + body.set("redirect_uri", this.redirectURI); + const request = createOAuth2Request(this.tokenEndpoint, body); + const encodedCredentials = encodeBasicCredentials(this.clientId, this.clientSecret); + request.headers.set("Authorization", `Basic ${encodedCredentials}`); + const tokens = await sendTokenRequest(request); return tokens; } - public async refreshAccessToken(refreshToken: string): Promise { - const result = await this.client.refreshAccessToken(refreshToken, { - authenticateWith: "request_body", - credentials: this.clientSecret - }); - const tokens: GitLabTokens = { - accessToken: result.access_token, - accessTokenExpiresAt: createDate(new TimeSpan(result.expires_in, "s")), - refreshToken: result.refresh_token - }; + public async refreshAccessToken(refreshToken: string): Promise { + const body = new URLSearchParams(); + body.set("grant_type", "refresh_token"); + body.set("refresh_token", refreshToken); + const request = createOAuth2Request(this.tokenEndpoint, body); + const encodedCredentials = encodeBasicCredentials(this.clientId, this.clientSecret); + request.headers.set("Authorization", `Basic ${encodedCredentials}`); + const tokens = await sendTokenRequest(request); return tokens; } -} -interface TokenResponseBody { - access_token: string; - expires_in: number; - refresh_token: string; -} - -export interface GitLabTokens { - accessToken: string; - accessTokenExpiresAt: Date; - refreshToken: string; + public async revokeToken(token: string): Promise { + const body = new URLSearchParams(); + body.set("token", token); + const request = createOAuth2Request(this.tokenRevocationEndpoint, body); + const encodedCredentials = encodeBasicCredentials(this.clientId, this.clientSecret); + request.headers.set("Authorization", `Basic ${encodedCredentials}`); + await sendTokenRevocationRequest(request); + } } diff --git a/src/providers/google.ts b/src/providers/google.ts index 2a83099a..04b97ee9 100644 --- a/src/providers/google.ts +++ b/src/providers/google.ts @@ -1,91 +1,69 @@ -import { TimeSpan, createDate } from "oslo"; -import { OAuth2Client } from "oslo/oauth2"; +import { createS256CodeChallenge } from "../oauth2.js"; +import { createOAuth2Request, sendTokenRequest, sendTokenRevocationRequest } from "../request.js"; -import type { OAuth2ProviderWithPKCE } from "../index.js"; +import type { OAuth2Tokens } from "../oauth2.js"; -const authorizeEndpoint = "https://accounts.google.com/o/oauth2/v2/auth"; +const authorizationEndpoint = "https://accounts.google.com/o/oauth2/v2/auth"; const tokenEndpoint = "https://oauth2.googleapis.com/token"; +const tokenRevocationEndpoint = "https://oauth2.googleapis.com/revoke"; -export class Google implements OAuth2ProviderWithPKCE { - private client: OAuth2Client; +export class Google { + private clientId: string; private clientSecret: string; + private redirectURI: string; constructor(clientId: string, clientSecret: string, redirectURI: string) { - this.client = new OAuth2Client(clientId, authorizeEndpoint, tokenEndpoint, { - redirectURI - }); + this.clientId = clientId; this.clientSecret = clientSecret; + this.redirectURI = redirectURI; } - public async createAuthorizationURL( - state: string, - codeVerifier: string, - options?: { - scopes?: string[]; - } - ): Promise { - const scopes = options?.scopes ?? []; - return await this.client.createAuthorizationURL({ - state, - codeVerifier, - scopes: [...scopes, "openid"] - }); + public createAuthorizationURL(state: string, codeVerifier: string, scopes: string[]): URL { + const url = new URL(authorizationEndpoint); + url.searchParams.set("response_type", "code"); + url.searchParams.set("client_id", this.clientId); + url.searchParams.set("state", state); + url.searchParams.set("scope", scopes.join(" ")); + url.searchParams.set("redirect_uri", this.redirectURI); + const codeChallenge = createS256CodeChallenge(codeVerifier); + url.searchParams.set("code_challenge_method", "S256"); + url.searchParams.set("code_challenge", codeChallenge); + return url; } public async validateAuthorizationCode( code: string, codeVerifier: string - ): Promise { - const result = await this.client.validateAuthorizationCode( - code, - { - authenticateWith: "request_body", - credentials: this.clientSecret, - codeVerifier - } - ); - const tokens: GoogleTokens = { - accessToken: result.access_token, - refreshToken: result.refresh_token ?? null, - accessTokenExpiresAt: createDate(new TimeSpan(result.expires_in, "s")), - idToken: result.id_token - }; + ): Promise { + const body = new URLSearchParams(); + body.set("grant_type", "authorization_code"); + body.set("code", code); + body.set("code_verifier", codeVerifier); + body.set("redirect_uri", this.redirectURI); + body.set("client_id", this.clientId); + body.set("client_secret", this.clientSecret); + const request = createOAuth2Request(tokenEndpoint, body); + const tokens = await sendTokenRequest(request); return tokens; } - public async refreshAccessToken(refreshToken: string): Promise { - const result = await this.client.refreshAccessToken(refreshToken, { - authenticateWith: "request_body", - credentials: this.clientSecret - }); - const tokens: GoogleRefreshedTokens = { - accessToken: result.access_token, - accessTokenExpiresAt: createDate(new TimeSpan(result.expires_in, "s")) - }; + public async refreshAccessToken(refreshToken: string): Promise { + const body = new URLSearchParams(); + body.set("grant_type", "refresh_token"); + body.set("refresh_token", refreshToken); + body.set("client_id", this.clientId); + body.set("client_secret", this.clientSecret); + const request = createOAuth2Request(tokenEndpoint, body); + const tokens = await sendTokenRequest(request); return tokens; } -} - -interface AuthorizationCodeResponseBody { - access_token: string; - refresh_token?: string; - expires_in: number; - id_token: string; -} - -interface RefreshTokenResponseBody { - access_token: string; - expires_in: number; -} - -export interface GoogleTokens { - accessToken: string; - refreshToken: string | null; - accessTokenExpiresAt: Date; - idToken: string; -} -export interface GoogleRefreshedTokens { - accessToken: string; - accessTokenExpiresAt: Date; + public async revokeToken(token: string): Promise { + const body = new URLSearchParams(); + body.set("token", token); + body.set("client_id", this.clientId); + body.set("client_secret", this.clientSecret); + const request = createOAuth2Request(tokenRevocationEndpoint, body); + await sendTokenRevocationRequest(request); + } } diff --git a/src/providers/intuit.ts b/src/providers/intuit.ts index 706ebf8a..a557f2c3 100644 --- a/src/providers/intuit.ts +++ b/src/providers/intuit.ts @@ -1,77 +1,66 @@ -import { OAuth2Client } from "oslo/oauth2"; -import { TimeSpan, createDate } from "oslo"; +import { + createOAuth2Request, + encodeBasicCredentials, + sendTokenRequest, + sendTokenRevocationRequest +} from "../request.js"; -import type { OAuth2Provider } from "../index.js"; +import type { OAuth2Tokens } from "../oauth2.js"; -const authorizeEndpoint = "https://appcenter.intuit.com/connect/oauth2"; +const authorizationEndpoint = "https://appcenter.intuit.com/connect/oauth2"; const tokenEndpoint = "https://oauth.platform.intuit.com/oauth2/v1/tokens/bearer"; +const tokenRevocationEndpoint = "https://developer.API.intuit.com/v2/oauth2/tokens/revoke"; -export class Intuit implements OAuth2Provider { - private client: OAuth2Client; +export class Intuit { + private clientId: string; private clientSecret: string; + private redirectURI: string; constructor(clientId: string, clientSecret: string, redirectURI: string) { - this.client = new OAuth2Client(clientId, authorizeEndpoint, tokenEndpoint, { - redirectURI - }); + this.clientId = clientId; this.clientSecret = clientSecret; + this.redirectURI = redirectURI; } - public async createAuthorizationURL( - state: string, - options?: { - scopes?: string[]; - } - ): Promise { - const scopes = options?.scopes ?? []; - return await this.client.createAuthorizationURL({ - state, - scopes: [...scopes, "openid"] - }); + public createAuthorizationURL(state: string, scopes: string[]): URL { + const url = new URL(authorizationEndpoint); + url.searchParams.set("response_type", "code"); + url.searchParams.set("client_id", this.clientId); + url.searchParams.set("state", state); + url.searchParams.set("scope", scopes.join(" ")); + url.searchParams.set("redirect_uri", this.redirectURI); + return url; } - public async validateAuthorizationCode(code: string): Promise { - const result = await this.client.validateAuthorizationCode(code, { - credentials: this.clientSecret - }); - const tokens: IntuitTokens = { - accessToken: result.access_token, - accessTokenExpiresAt: createDate(new TimeSpan(result.expires_in, "s")), - refreshToken: result.refresh_token, - refreshTokenExpiresAt: createDate(new TimeSpan(result.x_refresh_token_expires_in, "s")), - idToken: result.id_token - }; + public async validateAuthorizationCode(code: string): Promise { + const body = new URLSearchParams(); + body.set("grant_type", "authorization_code"); + body.set("code", code); + body.set("redirect_uri", this.redirectURI); + const request = createOAuth2Request(tokenEndpoint, body); + const encodedCredentials = encodeBasicCredentials(this.clientId, this.clientSecret); + request.headers.set("Authorization", `Basic ${encodedCredentials}`); + const tokens = await sendTokenRequest(request); return tokens; } - public async refreshAccessToken(accessToken: string): Promise { - const result = await this.client.refreshAccessToken(accessToken, { - credentials: this.clientSecret - }); - const tokens: IntuitTokens = { - accessToken: result.access_token, - accessTokenExpiresAt: createDate(new TimeSpan(result.expires_in, "s")), - refreshToken: result.refresh_token, - refreshTokenExpiresAt: createDate(new TimeSpan(result.x_refresh_token_expires_in, "s")), - idToken: result.id_token - }; + public async refreshAccessToken(refreshToken: string): Promise { + const body = new URLSearchParams(); + body.set("grant_type", "refresh_token"); + body.set("refresh_token", refreshToken); + const request = createOAuth2Request(tokenEndpoint, body); + const encodedCredentials = encodeBasicCredentials(this.clientId, this.clientSecret); + request.headers.set("Authorization", `Basic ${encodedCredentials}`); + const tokens = await sendTokenRequest(request); return tokens; } -} - -interface TokenResponseBody { - token_type: "bearer"; - expires_in: number; - refresh_token: string; - x_refresh_token_expires_in: number; - access_token: string; - id_token: string; -} -export interface IntuitTokens { - accessToken: string; - accessTokenExpiresAt: Date; - refreshToken: string; - refreshTokenExpiresAt: Date; - idToken: string; + public async revokeToken(token: string): Promise { + const body = new URLSearchParams(); + body.set("token", token); + const request = createOAuth2Request(tokenRevocationEndpoint, body); + const encodedCredentials = encodeBasicCredentials(this.clientId, this.clientSecret); + request.headers.set("Authorization", `Basic ${encodedCredentials}`); + await sendTokenRevocationRequest(request); + } } diff --git a/src/providers/kakao.ts b/src/providers/kakao.ts index 6eb843a6..17488179 100644 --- a/src/providers/kakao.ts +++ b/src/providers/kakao.ts @@ -1,73 +1,51 @@ -import { TimeSpan, createDate } from "oslo"; -import { OAuth2Client } from "oslo/oauth2"; +import { createOAuth2Request, sendTokenRequest } from "../request.js"; -import type { OAuth2Provider } from "../index.js"; +import type { OAuth2Tokens } from "../oauth2.js"; -const authorizeEndpoint = "https://kauth.kakao.com/oauth/authorize"; +const authorizationEndpoint = "https://kauth.kakao.com/oauth/authorize"; const tokenEndpoint = "https://kauth.kakao.com/oauth/token"; -export class Kakao implements OAuth2Provider { - private client: OAuth2Client; +export class Kakao { + private clientId: string; private clientSecret: string; + private redirectURI: string; constructor(clientId: string, clientSecret: string, redirectURI: string) { - this.client = new OAuth2Client(clientId, authorizeEndpoint, tokenEndpoint, { - redirectURI - }); + this.clientId = clientId; this.clientSecret = clientSecret; + this.redirectURI = redirectURI; } - public async createAuthorizationURL( - state: string, - options?: { - scopes?: string[]; - } - ): Promise { - return await this.client.createAuthorizationURL({ - state, - scopes: options?.scopes ?? [] - }); + public createAuthorizationURL(state: string, scopes: string[]): URL { + const url = new URL(authorizationEndpoint); + url.searchParams.set("response_type", "code"); + url.searchParams.set("client_id", this.clientId); + url.searchParams.set("state", state); + url.searchParams.set("scope", scopes.join(" ")); + url.searchParams.set("redirect_uri", this.redirectURI); + return url; } - public async validateAuthorizationCode(code: string): Promise { - const result = await this.client.validateAuthorizationCode(code, { - authenticateWith: "request_body", - credentials: this.clientSecret - }); - const tokens: KakaoTokens = { - accessToken: result.access_token, - accessTokenExpiresAt: createDate(new TimeSpan(result.expires_in, "s")), - refreshToken: result.refresh_token, - refreshTokenExpiresAt: createDate(new TimeSpan(result.refresh_token_expires_in, "s")) - }; + public async validateAuthorizationCode(code: string): Promise { + const body = new URLSearchParams(); + body.set("grant_type", "authorization_code"); + body.set("code", code); + body.set("redirect_uri", this.redirectURI); + body.set("client_id", this.clientId); + body.set("client_secret", this.clientSecret); + const request = createOAuth2Request(tokenEndpoint, body); + const tokens = await sendTokenRequest(request); return tokens; } - public async refreshAccessToken(refreshToken: string): Promise { - const result = await this.client.refreshAccessToken(refreshToken, { - authenticateWith: "request_body", - credentials: this.clientSecret - }); - const tokens: KakaoTokens = { - accessToken: result.access_token, - accessTokenExpiresAt: createDate(new TimeSpan(result.expires_in, "s")), - refreshToken: result.refresh_token, - refreshTokenExpiresAt: createDate(new TimeSpan(result.refresh_token_expires_in, "s")) - }; + public async refreshAccessToken(refreshToken: string): Promise { + const body = new URLSearchParams(); + body.set("grant_type", "refresh_token"); + body.set("refresh_token", refreshToken); + body.set("client_id", this.clientId); + body.set("client_secret", this.clientSecret); + const request = createOAuth2Request(tokenEndpoint, body); + const tokens = await sendTokenRequest(request); return tokens; } } - -interface TokenResponseBody { - access_token: string; - expires_in: number; - refresh_token: string; - refresh_token_expires_in: number; -} - -export interface KakaoTokens { - accessToken: string; - accessTokenExpiresAt: Date; - refreshToken: string; - refreshTokenExpiresAt: Date; -} diff --git a/src/providers/keycloak.ts b/src/providers/keycloak.ts index bdb8ca46..a8d6f19d 100644 --- a/src/providers/keycloak.ts +++ b/src/providers/keycloak.ts @@ -1,82 +1,77 @@ -import { OAuth2Client } from "oslo/oauth2"; -import { TimeSpan, createDate } from "oslo"; +import { createS256CodeChallenge } from "../oauth2.js"; +import { + createOAuth2Request, + encodeBasicCredentials, + sendTokenRequest, + sendTokenRevocationRequest +} from "../request.js"; -import type { OAuth2ProviderWithPKCE } from "../index.js"; +import type { OAuth2Tokens } from "../oauth2.js"; -export class Keycloak implements OAuth2ProviderWithPKCE { - private client: OAuth2Client; - private realmURL: string; +export class KeyCloak { + private authorizationEndpoint: string; + private tokenEndpoint: string; + private tokenRevocationEndpoint: string; + + private clientId: string; private clientSecret: string; + private redirectURI: string; constructor(realmURL: string, clientId: string, clientSecret: string, redirectURI: string) { - this.realmURL = realmURL; - const authorizeEndpoint = this.realmURL + "/protocol/openid-connect/auth"; - const tokenEndpoint = this.realmURL + "/protocol/openid-connect/token"; - this.client = new OAuth2Client(clientId, authorizeEndpoint, tokenEndpoint, { - redirectURI - }); + this.authorizationEndpoint = realmURL + "/protocol/openid-connect/auth"; + this.tokenEndpoint = realmURL + "/protocol/openid-connect/token"; + this.tokenRevocationEndpoint = realmURL + "/protocol/openid-connect/revoke"; + this.clientId = clientId; this.clientSecret = clientSecret; + this.redirectURI = redirectURI; } - public async createAuthorizationURL( - state: string, - codeVerifier: string, - options?: { - scopes?: string[]; - } - ): Promise { - const scopes = options?.scopes ?? []; - return await this.client.createAuthorizationURL({ - state, - codeVerifier, - scopes: [...scopes, "openid"] - }); + public createAuthorizationURL(state: string, codeVerifier: string, scopes: string[]): URL { + const url = new URL(this.authorizationEndpoint); + url.searchParams.set("response_type", "code"); + url.searchParams.set("client_id", this.clientId); + url.searchParams.set("state", state); + url.searchParams.set("scope", scopes.join(" ")); + url.searchParams.set("redirect_uri", this.redirectURI); + const codeChallenge = createS256CodeChallenge(codeVerifier); + url.searchParams.set("code_challenge_method", "S256"); + url.searchParams.set("code_challenge", codeChallenge); + return url; } + public async validateAuthorizationCode( code: string, codeVerifier: string - ): Promise { - const result = await this.client.validateAuthorizationCode(code, { - codeVerifier, - credentials: this.clientSecret - }); - const tokens: KeycloakTokens = { - accessToken: result.access_token, - accessTokenExpiresAt: createDate(new TimeSpan(result.expires_in, "s")), - refreshToken: result.refresh_token, - refreshTokenExpiresAt: createDate(new TimeSpan(result.refresh_expires_in, "s")), - idToken: result.id_token - }; + ): Promise { + const body = new URLSearchParams(); + body.set("grant_type", "authorization_code"); + body.set("code", code); + body.set("code_verifier", codeVerifier); + body.set("redirect_uri", this.redirectURI); + const request = createOAuth2Request(this.tokenEndpoint, body); + const encodedCredentials = encodeBasicCredentials(this.clientId, this.clientSecret); + request.headers.set("Authorization", `Basic ${encodedCredentials}`); + const tokens = await sendTokenRequest(request); return tokens; } - public async refreshAccessToken(refreshToken: string): Promise { - const result = await this.client.refreshAccessToken(refreshToken, { - credentials: this.clientSecret - }); - const tokens: KeycloakTokens = { - accessToken: result.access_token, - accessTokenExpiresAt: createDate(new TimeSpan(result.expires_in, "s")), - refreshToken: result.refresh_token, - refreshTokenExpiresAt: createDate(new TimeSpan(result.refresh_expires_in, "s")), - idToken: result.id_token - }; + public async refreshAccessToken(refreshToken: string): Promise { + const body = new URLSearchParams(); + body.set("grant_type", "refresh_token"); + body.set("refresh_token", refreshToken); + const request = createOAuth2Request(this.tokenEndpoint, body); + const encodedCredentials = encodeBasicCredentials(this.clientId, this.clientSecret); + request.headers.set("Authorization", `Basic ${encodedCredentials}`); + const tokens = await sendTokenRequest(request); return tokens; } -} -interface TokenResponseBody { - access_token: string; - refresh_token: string; - id_token: string; - expires_in: number; - refresh_expires_in: number; -} - -export interface KeycloakTokens { - accessToken: string; - accessTokenExpiresAt: Date; - refreshToken: string | null; - refreshTokenExpiresAt: Date | null; - idToken: string; + public async revokeToken(token: string): Promise { + const body = new URLSearchParams(); + body.set("token", token); + const request = createOAuth2Request(this.tokenRevocationEndpoint, body); + const encodedCredentials = encodeBasicCredentials(this.clientId, this.clientSecret); + request.headers.set("Authorization", `Basic ${encodedCredentials}`); + await sendTokenRevocationRequest(request); + } } diff --git a/src/providers/lichess.ts b/src/providers/lichess.ts index 000489e2..5565a0e5 100644 --- a/src/providers/lichess.ts +++ b/src/providers/lichess.ts @@ -1,46 +1,43 @@ -import { OAuth2Client } from "oslo/oauth2"; -import type { OAuth2ProviderWithPKCE } from "../index.js"; +import { createS256CodeChallenge } from "../oauth2.js"; +import { createOAuth2Request, sendTokenRequest } from "../request.js"; -const authorizeEndpoint = "https://lichess.org/oauth"; +import type { OAuth2Tokens } from "../oauth2.js"; + +const authorizationEndpoint = "https://lichess.org/oauth"; const tokenEndpoint = "https://lichess.org/api/token"; -export class Lichess implements OAuth2ProviderWithPKCE { - private client: OAuth2Client; +export class Lichess { + private clientId: string; + private redirectURI: string; constructor(clientId: string, redirectURI: string) { - this.client = new OAuth2Client(clientId, authorizeEndpoint, tokenEndpoint, { - redirectURI - }); + this.clientId = clientId; + this.redirectURI = redirectURI; } - - public async createAuthorizationURL( - state: string, - codeVerifier: string, - options?: { - scopes?: string[]; - } - ): Promise { - return await this.client.createAuthorizationURL({ - state, - scopes: options?.scopes ?? [], - codeVerifier - }); + public createAuthorizationURL(state: string, codeVerifier: string, scopes: string[]): URL { + const url = new URL(authorizationEndpoint); + url.searchParams.set("response_type", "code"); + url.searchParams.set("client_id", this.clientId); + url.searchParams.set("state", state); + url.searchParams.set("scope", scopes.join(" ")); + url.searchParams.set("redirect_uri", this.redirectURI); + const codeChallenge = createS256CodeChallenge(codeVerifier); + url.searchParams.set("code_challenge_method", "S256"); + url.searchParams.set("code_challenge", codeChallenge); + return url; } public async validateAuthorizationCode( code: string, codeVerifier: string - ): Promise { - const result = await this.client.validateAuthorizationCode(code, { - codeVerifier - }); - const tokens: LichessTokens = { - accessToken: result.access_token - }; + ): Promise { + const body = new URLSearchParams(); + body.set("grant_type", "authorization_code"); + body.set("code", code); + body.set("code_verifier", codeVerifier); + body.set("redirect_uri", this.redirectURI); + const request = createOAuth2Request(tokenEndpoint, body); + const tokens = await sendTokenRequest(request); return tokens; } } - -export interface LichessTokens { - accessToken: string; -} diff --git a/src/providers/line.ts b/src/providers/line.ts index c9c17a84..651ed07b 100644 --- a/src/providers/line.ts +++ b/src/providers/line.ts @@ -1,91 +1,59 @@ -import { OAuth2Client } from "oslo/oauth2"; -import { TimeSpan, createDate } from "oslo"; +import { createS256CodeChallenge } from "../oauth2.js"; +import { createOAuth2Request, sendTokenRequest } from "../request.js"; -import type { OAuth2ProviderWithPKCE } from "../index.js"; +import type { OAuth2Tokens } from "../oauth2.js"; -const authorizeEndpoint = "https://access.line.me/oauth2/v2.1/authorize"; +const authorizationEndpoint = "https://access.line.me/oauth2/v2.1/authorize"; const tokenEndpoint = "https://api.line.me/oauth2/v2.1/token"; -export class Line implements OAuth2ProviderWithPKCE { - private client: OAuth2Client; +export class Line { + private clientId: string; private clientSecret: string; + private redirectURI: string; constructor(clientId: string, clientSecret: string, redirectURI: string) { - this.client = new OAuth2Client(clientId, authorizeEndpoint, tokenEndpoint, { - redirectURI - }); - + this.clientId = clientId; this.clientSecret = clientSecret; + this.redirectURI = redirectURI; } - public async createAuthorizationURL( - state: string, - codeVerifier: string, - options?: { - scopes?: string[]; - } - ): Promise { - const scopes = options?.scopes ?? []; - return await this.client.createAuthorizationURL({ - state, - codeVerifier, - scopes: [...scopes, "openid"] - }); + public createAuthorizationURL(state: string, codeVerifier: string, scopes: string[]): URL { + const url = new URL(authorizationEndpoint); + url.searchParams.set("response_type", "code"); + url.searchParams.set("client_id", this.clientId); + url.searchParams.set("state", state); + url.searchParams.set("scope", scopes.join(" ")); + url.searchParams.set("redirect_uri", this.redirectURI); + const codeChallenge = createS256CodeChallenge(codeVerifier); + url.searchParams.set("code_challenge_method", "S256"); + url.searchParams.set("code_challenge", codeChallenge); + return url; } - public async validateAuthorizationCode(code: string, codeVerifier: string): Promise { - const result = await this.client.validateAuthorizationCode( - code, - { - authenticateWith: "request_body", - credentials: this.clientSecret, - codeVerifier - } - ); - const tokens: LineTokens = { - accessToken: result.access_token, - refreshToken: result.refresh_token, - accessTokenExpiresAt: createDate(new TimeSpan(result.expires_in, "s")), - idToken: result.id_token - }; + public async validateAuthorizationCode( + code: string, + codeVerifier: string + ): Promise { + const body = new URLSearchParams(); + body.set("grant_type", "authorization_code"); + body.set("code", code); + body.set("code_verifier", codeVerifier); + body.set("redirect_uri", this.redirectURI); + body.set("client_id", this.clientId); + body.set("client_secret", this.clientSecret); + const request = createOAuth2Request(tokenEndpoint, body); + const tokens = await sendTokenRequest(request); return tokens; } - public async refreshAccessToken(refreshToken: string): Promise { - const result = await this.client.refreshAccessToken(refreshToken, { - authenticateWith: "request_body", - credentials: this.clientSecret - }); - const tokens: LineRefreshedTokens = { - accessToken: result.access_token, - refreshToken: result.refresh_token, - accessTokenExpiresAt: createDate(new TimeSpan(result.expires_in, "s")) - }; + public async refreshAccessToken(refreshToken: string): Promise { + const body = new URLSearchParams(); + body.set("grant_type", "refresh_token"); + body.set("refresh_token", refreshToken); + body.set("client_id", this.clientId); + body.set("client_secret", this.clientSecret); + const request = createOAuth2Request(tokenEndpoint, body); + const tokens = await sendTokenRequest(request); return tokens; } } - -interface AuthorizationCodeResponseBody { - access_token: string; - expires_in: number; - refresh_token: string; - id_token: string; -} -interface RefreshTokenResponseBody { - access_token: string; - expires_in: number; - refresh_token: string; -} - -export interface LineRefreshedTokens { - accessToken: string; - refreshToken: string; - accessTokenExpiresAt: Date; -} - -export interface LineTokens { - accessToken: string; - refreshToken: string; - accessTokenExpiresAt: Date; - idToken: string; -} diff --git a/src/providers/linear.ts b/src/providers/linear.ts index f793a43f..bc61c431 100644 --- a/src/providers/linear.ts +++ b/src/providers/linear.ts @@ -1,54 +1,40 @@ -import { OAuth2Client } from "oslo/oauth2"; -import { TimeSpan, createDate } from "oslo"; +import { createOAuth2Request, sendTokenRequest } from "../request.js"; -import type { OAuth2Provider } from "../index.js"; +import type { OAuth2Tokens } from "../oauth2.js"; -const authorizeEndpoint = "https://linear.app/oauth/authorize"; +const authorizationEndpoint = "https://linear.app/oauth/authorize"; const tokenEndpoint = "https://api.linear.app/oauth/token"; -export class Linear implements OAuth2Provider { - private client: OAuth2Client; +export class Linear { + private clientId: string; private clientSecret: string; + private redirectURI: string; constructor(clientId: string, clientSecret: string, redirectURI: string) { - this.client = new OAuth2Client(clientId, authorizeEndpoint, tokenEndpoint, { - redirectURI - }); + this.clientId = clientId; this.clientSecret = clientSecret; + this.redirectURI = redirectURI; } - public async createAuthorizationURL( - state: string, - options?: { - scopes?: string[]; - } - ): Promise { - const scopes = options?.scopes ?? []; - return await this.client.createAuthorizationURL({ - state, - scopes: [...scopes, "read"] - }); + public createAuthorizationURL(state: string, scopes: string[]): URL { + const url = new URL(authorizationEndpoint); + url.searchParams.set("response_type", "code"); + url.searchParams.set("client_id", this.clientId); + url.searchParams.set("state", state); + url.searchParams.set("scope", scopes.join(" ")); + url.searchParams.set("redirect_uri", this.redirectURI); + return url; } - public async validateAuthorizationCode(code: string): Promise { - const result = await this.client.validateAuthorizationCode(code, { - authenticateWith: "request_body", - credentials: this.clientSecret - }); - const tokens: LinearTokens = { - accessToken: result.access_token, - accessTokenExpiresAt: createDate(new TimeSpan(result.expires_in, "s")) - }; + public async validateAuthorizationCode(code: string): Promise { + const body = new URLSearchParams(); + body.set("grant_type", "authorization_code"); + body.set("code", code); + body.set("redirect_uri", this.redirectURI); + body.set("client_id", this.clientId); + body.set("client_secret", this.clientSecret); + const request = createOAuth2Request(tokenEndpoint, body); + const tokens = await sendTokenRequest(request); return tokens; } } - -interface TokenResponseBody { - access_token: string; - expires_in: number; -} - -export interface LinearTokens { - accessToken: string; - accessTokenExpiresAt: Date; -} diff --git a/src/providers/linkedin.ts b/src/providers/linkedin.ts index dba783df..f368019a 100644 --- a/src/providers/linkedin.ts +++ b/src/providers/linkedin.ts @@ -1,96 +1,51 @@ -import { OAuth2Client } from "oslo/oauth2"; -import { TimeSpan, createDate } from "oslo"; +import { createOAuth2Request, sendTokenRequest } from "../request.js"; -import type { OAuth2Provider } from "../index.js"; +import type { OAuth2Tokens } from "../oauth2.js"; -const authorizeEndpoint = "https://www.linkedin.com/oauth/v2/authorization"; +const authorizationEndpoint = "https://www.linkedin.com/oauth/v2/authorization"; const tokenEndpoint = "https://www.linkedin.com/oauth/v2/accessToken"; -export class LinkedIn implements OAuth2Provider { - private client: OAuth2Client; +export class LinkedIn { + private clientId: string; private clientSecret: string; + private redirectURI: string; constructor(clientId: string, clientSecret: string, redirectURI: string) { - this.client = new OAuth2Client(clientId, authorizeEndpoint, tokenEndpoint, { - redirectURI - }); + this.clientId = clientId; this.clientSecret = clientSecret; + this.redirectURI = redirectURI; } - public async createAuthorizationURL( - state: string, - options?: { - scopes?: string[]; - } - ): Promise { - const scopes = options?.scopes ?? []; - return await this.client.createAuthorizationURL({ - state, - scopes: [...scopes, "openid"] - }); + public createAuthorizationURL(state: string, scopes: string[]): URL { + const url = new URL(authorizationEndpoint); + url.searchParams.set("response_type", "code"); + url.searchParams.set("client_id", this.clientId); + url.searchParams.set("state", state); + url.searchParams.set("scope", scopes.join(" ")); + url.searchParams.set("redirect_uri", this.redirectURI); + return url; } - public async validateAuthorizationCode(code: string): Promise { - const result = await this.client.validateAuthorizationCode( - code, - { - authenticateWith: "request_body", - credentials: this.clientSecret - } - ); - const tokens: LinkedInTokens = { - idToken: result.id_token, - accessToken: result.access_token, - accessTokenExpiresAt: createDate(new TimeSpan(result.expires_in, "s")), - refreshToken: result.refresh_token ?? null, - refreshTokenExpiresAt: result.refresh_token_expires_in - ? createDate(new TimeSpan(result.refresh_token_expires_in, "s")) - : null - }; + public async validateAuthorizationCode(code: string): Promise { + const body = new URLSearchParams(); + body.set("grant_type", "authorization_code"); + body.set("code", code); + body.set("redirect_uri", this.redirectURI); + body.set("client_id", this.clientId); + body.set("client_secret", this.clientSecret); + const request = createOAuth2Request(tokenEndpoint, body); + const tokens = await sendTokenRequest(request); return tokens; } - public async refreshAccessToken(accessToken: string): Promise { - const result = await this.client.refreshAccessToken(accessToken, { - authenticateWith: "request_body", - credentials: this.clientSecret - }); - const tokens: LinkedInRefreshedTokens = { - accessToken: result.access_token, - accessTokenExpiresAt: createDate(new TimeSpan(result.expires_in, "s")), - refreshToken: result.refresh_token, - refreshTokenExpiresAt: createDate(new TimeSpan(result.refresh_token_expires_in, "s")) - }; + public async refreshAccessToken(refreshToken: string): Promise { + const body = new URLSearchParams(); + body.set("grant_type", "refresh_token"); + body.set("refresh_token", refreshToken); + body.set("client_id", this.clientId); + body.set("client_secret", this.clientSecret); + const request = createOAuth2Request(tokenEndpoint, body); + const tokens = await sendTokenRequest(request); return tokens; } } - -interface AuthorizationCodeResponseBody { - id_token: string; - access_token: string; - expires_in: number; - refresh_token?: string; // available only if your application is authorized for programmatic refresh tokens - refresh_token_expires_in?: number; -} - -interface RefreshTokenResponseBody { - access_token: string; - expires_in: number; - refresh_token: string; - refresh_token_expires_in: number; -} - -export interface LinkedInTokens { - idToken: string; - accessToken: string; - accessTokenExpiresAt: Date; - refreshToken: string | null; - refreshTokenExpiresAt: Date | null; -} - -export interface LinkedInRefreshedTokens { - accessToken: string; - accessTokenExpiresAt: Date; - refreshToken: string; - refreshTokenExpiresAt: Date; -} diff --git a/src/providers/microsoft-entra-id.ts b/src/providers/microsoft-entra-id.ts index 8faad599..3489ebc7 100644 --- a/src/providers/microsoft-entra-id.ts +++ b/src/providers/microsoft-entra-id.ts @@ -1,79 +1,61 @@ -import { TimeSpan, createDate } from "oslo"; -import { OAuth2Client } from "oslo/oauth2"; +import { createS256CodeChallenge } from "../oauth2.js"; +import { createOAuth2Request, encodeBasicCredentials, sendTokenRequest } from "../request.js"; -import type { OAuth2ProviderWithPKCE } from "../index.js"; +import type { OAuth2Tokens } from "../oauth2.js"; -export class MicrosoftEntraId implements OAuth2ProviderWithPKCE { - private client: OAuth2Client; +export class MicrosoftEntraId { + private authorizationEndpoint: string; + private tokenEndpoint: string; + + private clientId: string; private clientSecret: string; + private redirectURI: string; constructor(tenant: string, clientId: string, clientSecret: string, redirectURI: string) { - const authorizeEndpoint = `https://login.microsoftonline.com/${tenant}/oauth2/v2.0/authorize`; - const tokenEndpoint = `https://login.microsoftonline.com/${tenant}/oauth2/v2.0/token`; - this.client = new OAuth2Client(clientId, authorizeEndpoint, tokenEndpoint, { - redirectURI - }); + this.authorizationEndpoint = `https://login.microsoftonline.com/${tenant}/oauth2/v2.0/authorize`; + this.tokenEndpoint = `https://login.microsoftonline.com/${tenant}/oauth2/v2.0/token`; + this.clientId = clientId; this.clientSecret = clientSecret; + this.redirectURI = redirectURI; } - public async createAuthorizationURL( - state: string, - codeVerifier: string, - options?: { - scopes?: string[]; - } - ): Promise { - const scopes = options?.scopes ?? []; - const url = await this.client.createAuthorizationURL({ - state, - codeVerifier, - scopes: [...scopes, "openid"] - }); - url.searchParams.set("nonce", "_"); + public createAuthorizationURL(state: string, codeVerifier: string, scopes: string[]): URL { + const url = new URL(this.authorizationEndpoint); + url.searchParams.set("response_type", "code"); + url.searchParams.set("client_id", this.clientId); + url.searchParams.set("state", state); + url.searchParams.set("scope", scopes.join(" ")); + url.searchParams.set("redirect_uri", this.redirectURI); + const codeChallenge = createS256CodeChallenge(codeVerifier); + url.searchParams.set("code_challenge_method", "S256"); + url.searchParams.set("code_challenge", codeChallenge); return url; } public async validateAuthorizationCode( code: string, codeVerifier: string - ): Promise { - const result = await this.client.validateAuthorizationCode(code, { - credentials: this.clientSecret, - codeVerifier - }); - const tokens: MicrosoftEntraIdTokens = { - accessToken: result.access_token, - refreshToken: result.refresh_token ?? null, - accessTokenExpiresAt: createDate(new TimeSpan(result.expires_in, "s")), - idToken: result.id_token - }; + ): Promise { + const body = new URLSearchParams(); + body.set("grant_type", "authorization_code"); + body.set("code", code); + body.set("code_verifier", codeVerifier); + body.set("redirect_uri", this.redirectURI); + const request = createOAuth2Request(this.tokenEndpoint, body); + const encodedCredentials = encodeBasicCredentials(this.clientId, this.clientSecret); + request.headers.set("Authorization", `Basic ${encodedCredentials}`); + const tokens = await sendTokenRequest(request); return tokens; } - public async refreshAccessToken(refreshToken: string): Promise { - const result = await this.client.refreshAccessToken(refreshToken, { - credentials: this.clientSecret - }); - const tokens: MicrosoftEntraIdTokens = { - accessToken: result.access_token, - refreshToken: result.refresh_token ?? null, - accessTokenExpiresAt: createDate(new TimeSpan(result.expires_in, "s")), - idToken: result.id_token - }; + public async refreshAccessToken(refreshToken: string): Promise { + const body = new URLSearchParams(); + body.set("grant_type", "refresh_token"); + body.set("refresh_token", refreshToken); + const request = createOAuth2Request(this.tokenEndpoint, body); + const encodedCredentials = encodeBasicCredentials(this.clientId, this.clientSecret); + request.headers.set("Authorization", `Basic ${encodedCredentials}`); + const tokens = await sendTokenRequest(request); return tokens; } } - -interface TokenResponseBody { - access_token: string; - expires_in: number; - refresh_token?: string; - id_token: string; -} - -export interface MicrosoftEntraIdTokens { - idToken: string; - accessToken: string; - accessTokenExpiresAt: Date; - refreshToken: string | null; -} diff --git a/src/providers/myanimelist.ts b/src/providers/myanimelist.ts index 0d461a09..b79378c7 100644 --- a/src/providers/myanimelist.ts +++ b/src/providers/myanimelist.ts @@ -1,14 +1,14 @@ -import { TimeSpan, createDate } from "oslo"; -import { OAuth2Client } from "oslo/oauth2"; +import { createOAuth2Request, encodeBasicCredentials, sendTokenRequest } from "../request.js"; -import type { OAuth2ProviderWithPKCE } from "../index.js"; +import type { OAuth2Tokens } from "../oauth2.js"; -const authorizeEndpoint = "https://myanimelist.net/v1/oauth2/authorize"; +const authorizationEndpoint = "https://myanimelist.net/v1/oauth2/authorize"; const tokenEndpoint = "https://myanimelist.net/v1/oauth2/token"; -export class MyAnimeList implements OAuth2ProviderWithPKCE { - private client: OAuth2Client; +export class MyAnimeList { + private clientId: string; private clientSecret: string; + private redirectURI: string | null; constructor( clientId: string, @@ -17,57 +17,49 @@ export class MyAnimeList implements OAuth2ProviderWithPKCE { redirectURI?: string; } ) { - this.client = new OAuth2Client(clientId, authorizeEndpoint, tokenEndpoint, { - redirectURI: options?.redirectURI - }); + this.clientId = clientId; this.clientSecret = clientSecret; + this.redirectURI = options?.redirectURI ?? null; } - public async createAuthorizationURL(state: string, codeVerifier: string): Promise { - return await this.client.createAuthorizationURL({ - state, - codeVerifier, - codeChallengeMethod: "plain" - }); + public createAuthorizationURL(state: string, codeVerifier: string): URL { + const url = new URL(authorizationEndpoint); + url.searchParams.set("response_type", "code"); + url.searchParams.set("client_id", this.clientId); + url.searchParams.set("state", state); + if (this.redirectURI !== null) { + url.searchParams.set("redirect_uri", this.redirectURI); + } + url.searchParams.set("code_challenge_method", "plain"); + url.searchParams.set("code_challenge", codeVerifier); + return url; } - public async validateAuthorizationCode( - code: string, - codeVerifier: string - ): Promise { - const result = await this.client.validateAuthorizationCode(code, { - credentials: this.clientSecret, - codeVerifier - }); - const tokens: MyAnimeListTokens = { - accessToken: result.access_token, - refreshToken: result.refresh_token, - accessTokenExpiresAt: createDate(new TimeSpan(result.expires_in, "s")) - }; + public async validateAuthorizationCode(code: string): Promise { + const body = new URLSearchParams(); + body.set("grant_type", "authorization_code"); + body.set("code", code); + if (this.redirectURI !== null) { + body.set("redirect_uri", this.redirectURI); + } + const request = createOAuth2Request(tokenEndpoint, body); + const encodedCredentials = encodeBasicCredentials(this.clientId, this.clientSecret); + request.headers.set("Authorization", `Basic ${encodedCredentials}`); + const tokens = await sendTokenRequest(request); return tokens; } - public async refreshAccessToken(refreshToken: string): Promise { - const result = await this.client.refreshAccessToken(refreshToken, { - credentials: this.clientSecret - }); - const tokens: MyAnimeListTokens = { - accessToken: result.access_token, - refreshToken: result.refresh_token, - accessTokenExpiresAt: createDate(new TimeSpan(result.expires_in, "s")) - }; + public async refreshAccessToken(refreshToken: string): Promise { + const body = new URLSearchParams(); + body.set("grant_type", "refresh_token"); + body.set("refresh_token", refreshToken); + if (this.redirectURI !== null) { + body.set("redirect_uri", this.redirectURI); + } + const request = createOAuth2Request(tokenEndpoint, body); + const encodedCredentials = encodeBasicCredentials(this.clientId, this.clientSecret); + request.headers.set("Authorization", `Basic ${encodedCredentials}`); + const tokens = await sendTokenRequest(request); return tokens; } } - -interface TokenResponseBody { - access_token: string; - refresh_token: string; - expires_in: number; -} - -export interface MyAnimeListTokens { - accessToken: string; - refreshToken: string; - accessTokenExpiresAt: Date; -} diff --git a/src/providers/notion.ts b/src/providers/notion.ts index 9470b00d..2b0b540a 100644 --- a/src/providers/notion.ts +++ b/src/providers/notion.ts @@ -1,42 +1,40 @@ -import { OAuth2Client } from "oslo/oauth2"; +import { createOAuth2Request, encodeBasicCredentials, sendTokenRequest } from "../request.js"; -import type { OAuth2Provider } from "../index.js"; +import type { OAuth2Tokens } from "../oauth2.js"; -const authorizeEndpoint = "https://api.notion.com/v1/oauth/authorize"; +const authorizationEndpoint = "https://api.notion.com/v1/oauth/authorize"; const tokenEndpoint = "https://api.notion.com/v1/oauth/token"; -export class Notion implements OAuth2Provider { - private client: OAuth2Client; +export class Notion { + private clientId: string; private clientSecret: string; + private redirectURI: string; constructor(clientId: string, clientSecret: string, redirectURI: string) { - this.client = new OAuth2Client(clientId, authorizeEndpoint, tokenEndpoint, { - redirectURI - }); + this.clientId = clientId; this.clientSecret = clientSecret; + this.redirectURI = redirectURI; } - public async createAuthorizationURL(state: string): Promise { - return await this.client.createAuthorizationURL({ - state - }); + public createAuthorizationURL(state: string): URL { + const url = new URL(authorizationEndpoint); + url.searchParams.set("response_type", "code"); + url.searchParams.set("client_id", this.clientId); + url.searchParams.set("state", state); + url.searchParams.set("redirect_uri", this.redirectURI); + url.searchParams.set("owner", "user"); + return url; } - public async validateAuthorizationCode(code: string): Promise { - const result = await this.client.validateAuthorizationCode(code, { - credentials: this.clientSecret - }); - const tokens: NotionTokens = { - accessToken: result.access_token - }; + public async validateAuthorizationCode(code: string): Promise { + const body = new URLSearchParams(); + body.set("grant_type", "authorization_code"); + body.set("code", code); + body.set("redirect_uri", this.redirectURI); + const request = createOAuth2Request(tokenEndpoint, body); + const encodedCredentials = encodeBasicCredentials(this.clientId, this.clientSecret); + request.headers.set("Authorization", `Basic ${encodedCredentials}`); + const tokens = await sendTokenRequest(request); return tokens; } } - -interface TokenResponseBody { - access_token: string; -} - -export interface NotionTokens { - accessToken: string; -} diff --git a/src/providers/okta.ts b/src/providers/okta.ts index 6520c9a3..2a45aeb8 100644 --- a/src/providers/okta.ts +++ b/src/providers/okta.ts @@ -1,110 +1,88 @@ -import { OAuth2Client } from "oslo/oauth2"; -import { TimeSpan, createDate } from "oslo"; +import { createS256CodeChallenge } from "../oauth2.js"; +import { + createOAuth2Request, + encodeBasicCredentials, + sendTokenRequest, + sendTokenRevocationRequest +} from "../request.js"; -import type { OAuth2ProviderWithPKCE } from "../index.js"; +import type { OAuth2Tokens } from "../oauth2.js"; -export class Okta implements OAuth2ProviderWithPKCE { - private client: OAuth2Client; +export class Okta { + private authorizationEndpoint: string; + private tokenEndpoint: string; + private tokenRevocationEndpoint: string; + + private clientId: string; private clientSecret: string; + private redirectURI: string; constructor( - oktaDomain: string, + domain: string, + authorizationServerId: string | null, clientId: string, clientSecret: string, - redirectURI: string, - options?: { - authorizationServerId?: string; - } + redirectURI: string ) { - let authorizeEndpoint; - let tokenEndpoint; - - if (options?.authorizationServerId) { - authorizeEndpoint = `${oktaDomain}/oauth2/${options.authorizationServerId}/v1/authorize`; - tokenEndpoint = `${oktaDomain}/oauth2/${options.authorizationServerId}/v1/token`; - } else { - authorizeEndpoint = `${oktaDomain}/oauth2/v1/authorize`; - tokenEndpoint = `${oktaDomain}/oauth2/v1/token`; + let baseURL = `https://${domain}/oauth2`; + if (authorizationServerId !== null) { + baseURL = baseURL + `/${authorizationServerId}`; } - - this.client = new OAuth2Client(clientId, authorizeEndpoint, tokenEndpoint, { - redirectURI - }); + this.authorizationEndpoint = baseURL + "/v1/authorize"; + this.tokenEndpoint = baseURL + "/v1/token"; + this.tokenRevocationEndpoint = baseURL + "/v1/revoke"; + this.clientId = clientId; this.clientSecret = clientSecret; + this.redirectURI = redirectURI; } - public async createAuthorizationURL( - state: string, - codeVerifier: string, - options?: { - scopes?: string[]; - } - ): Promise { - const url = await this.client.createAuthorizationURL({ - codeVerifier, - scopes: [...(options?.scopes ?? []), "openid"] - }); + public createAuthorizationURL(state: string, codeVerifier: string, scopes: string[]): URL { + const url = new URL(this.authorizationEndpoint); + url.searchParams.set("response_type", "code"); + url.searchParams.set("client_id", this.clientId); url.searchParams.set("state", state); - + url.searchParams.set("scope", scopes.join(" ")); + url.searchParams.set("redirect_uri", this.redirectURI); + const codeChallenge = createS256CodeChallenge(codeVerifier); + url.searchParams.set("code_challenge_method", "S256"); + url.searchParams.set("code_challenge", codeChallenge); return url; } - public async validateAuthorizationCode(code: string, codeVerifier: string): Promise { - const result = await this.client.validateAuthorizationCode(code, { - codeVerifier, - credentials: this.clientSecret, - authenticateWith: "request_body" - }); - - const tokens: OktaTokens = { - accessToken: result.access_token, - refreshToken: result.refresh_token ?? null, - accessTokenExpiresAt: createDate(new TimeSpan(result.expires_in, "s")), - idToken: result.id_token, - deviceSecret: result.device_secret ?? null - }; - + public async validateAuthorizationCode( + code: string, + codeVerifier: string + ): Promise { + const body = new URLSearchParams(); + body.set("grant_type", "authorization_code"); + body.set("code", code); + body.set("code_verifier", codeVerifier); + body.set("redirect_uri", this.redirectURI); + const request = createOAuth2Request(this.tokenEndpoint, body); + const encodedCredentials = encodeBasicCredentials(this.clientId, this.clientSecret); + request.headers.set("Authorization", `Basic ${encodedCredentials}`); + const tokens = await sendTokenRequest(request); return tokens; } - public async refreshAccessToken( - refreshToken: string, - options?: { - scopes?: string[]; - } - ): Promise { - const result = await this.client.refreshAccessToken(refreshToken, { - credentials: this.clientSecret, - authenticateWith: "request_body", - scopes: options?.scopes ?? [] - }); - - const tokens: OktaTokens = { - accessToken: result.access_token, - refreshToken: result.refresh_token ?? null, - accessTokenExpiresAt: createDate(new TimeSpan(result.expires_in, "s")), - idToken: result.id_token, - deviceSecret: result.device_secret ?? null - }; - + public async refreshAccessToken(refreshToken: string, scopes: string[]): Promise { + const body = new URLSearchParams(); + body.set("grant_type", "refresh_token"); + body.set("refresh_token", refreshToken); + body.set("scope", scopes.join(" ")); + const request = createOAuth2Request(this.tokenEndpoint, body); + const encodedCredentials = encodeBasicCredentials(this.clientId, this.clientSecret); + request.headers.set("Authorization", `Basic ${encodedCredentials}`); + const tokens = await sendTokenRequest(request); return tokens; } -} -interface TokenResponseBody { - access_token: string; - token_type: string; - expires_in: number; - scope: string; - refresh_token?: string; - id_token: string; - device_secret?: string; -} - -export interface OktaTokens { - idToken: string; - accessToken: string; - accessTokenExpiresAt: Date; - refreshToken: string | null; - deviceSecret: string | null; + public async revokeToken(token: string): Promise { + const body = new URLSearchParams(); + body.set("token", token); + const request = createOAuth2Request(this.tokenRevocationEndpoint, body); + const encodedCredentials = encodeBasicCredentials(this.clientId, this.clientSecret); + request.headers.set("Authorization", `Basic ${encodedCredentials}`); + await sendTokenRevocationRequest(request); + } } diff --git a/src/providers/osu.ts b/src/providers/osu.ts index c155f4db..6a25acf5 100644 --- a/src/providers/osu.ts +++ b/src/providers/osu.ts @@ -1,75 +1,55 @@ -import { OAuth2Client } from "oslo/oauth2"; -import { TimeSpan, createDate } from "oslo"; +import { createOAuth2Request, sendTokenRequest } from "../request.js"; -import type { OAuth2Provider } from "../index.js"; +import type { OAuth2Tokens } from "../oauth2.js"; -const authorizeEndpoint = "https://osu.ppy.sh/oauth/authorize"; +const authorizationEndpoint = "https://osu.ppy.sh/oauth/authorize"; const tokenEndpoint = "https://osu.ppy.sh/oauth/token"; -export class Osu implements OAuth2Provider { - private client: OAuth2Client; +export class Osu { + private clientId: string; private clientSecret: string; + private redirectURI: string | null; - constructor( - clientId: string, - clientSecret: string, - options?: { - redirectURI?: string; - } - ) { - this.client = new OAuth2Client(clientId, authorizeEndpoint, tokenEndpoint, { - redirectURI: options?.redirectURI - }); + constructor(clientId: string, clientSecret: string, redirectURI: string | null) { + this.clientId = clientId; this.clientSecret = clientSecret; + this.redirectURI = redirectURI; } - public async createAuthorizationURL( - state: string, - options?: { - scopes?: string[]; + public createAuthorizationURL(state: string, scopes: string[]): URL { + const url = new URL(authorizationEndpoint); + url.searchParams.set("response_type", "code"); + url.searchParams.set("client_id", this.clientId); + url.searchParams.set("state", state); + url.searchParams.set("scope", scopes.join(" ")); + if (this.redirectURI !== null) { + url.searchParams.set("redirect_uri", this.redirectURI); } - ): Promise { - return await this.client.createAuthorizationURL({ - state, - scopes: options?.scopes ?? [] - }); + return url; } - public async validateAuthorizationCode(code: string): Promise { - const result = await this.client.validateAuthorizationCode(code, { - authenticateWith: "request_body", - credentials: this.clientSecret - }); - const tokens: OsuTokens = { - accessToken: result.access_token, - refreshToken: result.refresh_token, - accessTokenExpiresAt: createDate(new TimeSpan(result.expires_in, "s")) - }; + public async validateAuthorizationCode(code: string): Promise { + const body = new URLSearchParams(); + body.set("grant_type", "authorization_code"); + body.set("code", code); + if (this.redirectURI !== null) { + body.set("redirect_uri", this.redirectURI); + } + body.set("client_id", this.clientId); + body.set("client_secret", this.clientSecret); + const request = createOAuth2Request(tokenEndpoint, body); + const tokens = await sendTokenRequest(request); return tokens; } - public async refreshAccessToken(refreshToken: string): Promise { - const result = await this.client.refreshAccessToken(refreshToken, { - authenticateWith: "request_body", - credentials: this.clientSecret - }); - const tokens: OsuTokens = { - accessToken: result.access_token, - refreshToken: result.refresh_token, - accessTokenExpiresAt: createDate(new TimeSpan(result.expires_in, "s")) - }; + public async refreshAccessToken(refreshToken: string): Promise { + const body = new URLSearchParams(); + body.set("grant_type", "refresh_token"); + body.set("refresh_token", refreshToken); + body.set("client_id", this.clientId); + body.set("client_secret", this.clientSecret); + const request = createOAuth2Request(tokenEndpoint, body); + const tokens = await sendTokenRequest(request); return tokens; } } - -interface TokenResponseBody { - access_token: string; - expires_in: number; - refresh_token: string; -} - -export interface OsuTokens { - accessToken: string; - refreshToken: string; - accessTokenExpiresAt: Date; -} diff --git a/src/providers/patreon.ts b/src/providers/patreon.ts index 3133c286..0b38eb11 100644 --- a/src/providers/patreon.ts +++ b/src/providers/patreon.ts @@ -1,69 +1,51 @@ -import { OAuth2Client } from "oslo/oauth2"; -import { TimeSpan, createDate } from "oslo"; +import { createOAuth2Request, sendTokenRequest } from "../request.js"; -import type { OAuth2Provider } from "../index.js"; +import type { OAuth2Tokens } from "../oauth2.js"; -const authorizeEndpoint = "https://www.patreon.com/oauth2/authorize"; +const authorizationEndpoint = "https://www.patreon.com/oauth2/authorize"; const tokenEndpoint = "https://www.patreon.com/api/oauth2/token"; -export class Patreon implements OAuth2Provider { - private client: OAuth2Client; +export class Patreon { + private clientId: string; private clientSecret: string; + private redirectURI: string; constructor(clientId: string, clientSecret: string, redirectURI: string) { - this.client = new OAuth2Client(clientId, authorizeEndpoint, tokenEndpoint, { - redirectURI - }); + this.clientId = clientId; this.clientSecret = clientSecret; + this.redirectURI = redirectURI; } - public async createAuthorizationURL( - state: string, - options?: { - scopes?: string[]; - } - ): Promise { - return await this.client.createAuthorizationURL({ - state, - scopes: options?.scopes ?? [] - }); + public createAuthorizationURL(state: string, scopes: string[]): URL { + const url = new URL(authorizationEndpoint); + url.searchParams.set("response_type", "code"); + url.searchParams.set("client_id", this.clientId); + url.searchParams.set("state", state); + url.searchParams.set("scope", scopes.join(" ")); + url.searchParams.set("redirect_uri", this.redirectURI); + return url; } - public async validateAuthorizationCode(code: string): Promise { - const result = await this.client.validateAuthorizationCode(code, { - authenticateWith: "request_body", - credentials: this.clientSecret - }); - const tokens: PatreonTokens = { - accessToken: result.access_token, - refreshToken: result.refresh_token, - accessTokenExpiresAt: createDate(new TimeSpan(result.expires_in, "s")) - }; + public async validateAuthorizationCode(code: string): Promise { + const body = new URLSearchParams(); + body.set("grant_type", "authorization_code"); + body.set("code", code); + body.set("redirect_uri", this.redirectURI); + body.set("client_id", this.clientId); + body.set("client_secret", this.clientSecret); + const request = createOAuth2Request(tokenEndpoint, body); + const tokens = await sendTokenRequest(request); return tokens; } - public async refreshAccessToken(refreshToken: string): Promise { - const result = await this.client.refreshAccessToken(refreshToken, { - authenticateWith: "request_body", - credentials: this.clientSecret - }); - const tokens: PatreonTokens = { - accessToken: result.access_token, - refreshToken: result.refresh_token, - accessTokenExpiresAt: createDate(new TimeSpan(result.expires_in, "s")) - }; + public async refreshAccessToken(refreshToken: string): Promise { + const body = new URLSearchParams(); + body.set("grant_type", "refresh_token"); + body.set("refresh_token", refreshToken); + body.set("client_id", this.clientId); + body.set("client_secret", this.clientSecret); + const request = createOAuth2Request(tokenEndpoint, body); + const tokens = await sendTokenRequest(request); return tokens; } } - -interface TokenResponseBody { - access_token: string; - expires_in: number; - refresh_token: string; -} - -export interface PatreonTokens { - accessToken: string; - refreshToken: string; - accessTokenExpiresAt: Date; -} diff --git a/src/providers/reddit.ts b/src/providers/reddit.ts index 6bbb5dbf..f4763729 100644 --- a/src/providers/reddit.ts +++ b/src/providers/reddit.ts @@ -1,67 +1,51 @@ -import { OAuth2Client } from "oslo/oauth2"; -import { TimeSpan, createDate } from "oslo"; +import { createOAuth2Request, encodeBasicCredentials, sendTokenRequest } from "../request.js"; -import type { OAuth2Provider } from "../index.js"; +import type { OAuth2Tokens } from "../oauth2.js"; -const authorizeEndpoint = "https://www.reddit.com/api/v1/authorize"; +const authorizationEndpoint = "https://www.reddit.com/api/v1/authorize"; const tokenEndpoint = "https://www.reddit.com/api/v1/access_token"; -export class Reddit implements OAuth2Provider { - private client: OAuth2Client; +export class Reddit { + private clientId: string; private clientSecret: string; + private redirectURI: string; constructor(clientId: string, clientSecret: string, redirectURI: string) { - this.client = new OAuth2Client(clientId, authorizeEndpoint, tokenEndpoint, { - redirectURI - }); + this.clientId = clientId; this.clientSecret = clientSecret; + this.redirectURI = redirectURI; } - public async createAuthorizationURL( - state: string, - options?: { - scopes?: string[]; - } - ): Promise { - return await this.client.createAuthorizationURL({ - state, - scopes: options?.scopes ?? [] - }); + public createAuthorizationURL(state: string, scopes: string[]): URL { + const url = new URL(authorizationEndpoint); + url.searchParams.set("response_type", "code"); + url.searchParams.set("client_id", this.clientId); + url.searchParams.set("state", state); + url.searchParams.set("scope", scopes.join(" ")); + url.searchParams.set("redirect_uri", this.redirectURI); + return url; } - public async validateAuthorizationCode(code: string): Promise { - const result = await this.client.validateAuthorizationCode(code, { - credentials: this.clientSecret - }); - const tokens: RedditTokens = { - accessToken: result.access_token, - accessTokenExpiresAt: createDate(new TimeSpan(result.expires_in, "s")), - refreshToken: result.refresh_token ?? null - }; + public async validateAuthorizationCode(code: string): Promise { + const body = new URLSearchParams(); + body.set("grant_type", "authorization_code"); + body.set("code", code); + body.set("redirect_uri", this.redirectURI); + const request = createOAuth2Request(tokenEndpoint, body); + const encodedCredentials = encodeBasicCredentials(this.clientId, this.clientSecret); + request.headers.set("Authorization", `Basic ${encodedCredentials}`); + const tokens = await sendTokenRequest(request); return tokens; } - public async refreshAccessToken(refreshToken: string): Promise { - const result = await this.client.refreshAccessToken(refreshToken, { - credentials: this.clientSecret - }); - const tokens: RedditTokens = { - accessToken: result.access_token, - accessTokenExpiresAt: createDate(new TimeSpan(result.expires_in, "s")), - refreshToken: result.refresh_token ?? null - }; + public async refreshAccessToken(refreshToken: string): Promise { + const body = new URLSearchParams(); + body.set("grant_type", "refresh_token"); + body.set("refresh_token", refreshToken); + const request = createOAuth2Request(tokenEndpoint, body); + const encodedCredentials = encodeBasicCredentials(this.clientId, this.clientSecret); + request.headers.set("Authorization", `Basic ${encodedCredentials}`); + const tokens = await sendTokenRequest(request); return tokens; } } - -interface TokenResponseBody { - access_token: string; - refresh_token?: string; - expires_in: number; -} - -export interface RedditTokens { - accessToken: string; - refreshToken: string | null; - accessTokenExpiresAt: Date; -} diff --git a/src/providers/roblox.ts b/src/providers/roblox.ts index 23c80a75..e421ce7f 100644 --- a/src/providers/roblox.ts +++ b/src/providers/roblox.ts @@ -1,79 +1,69 @@ -import { TimeSpan, createDate } from "oslo"; -import { OAuth2Client } from "oslo/oauth2"; +import { createS256CodeChallenge } from "../oauth2.js"; +import { createOAuth2Request, sendTokenRequest, sendTokenRevocationRequest } from "../request.js"; -import type { OAuth2ProviderWithPKCE } from "../index.js"; +import type { OAuth2Tokens } from "../oauth2.js"; -const authorizeEndpoint = "https://apis.roblox.com/oauth/v1/authorize"; +const authorizationEndpoint = "https://apis.roblox.com/oauth/v1/authorize"; const tokenEndpoint = "https://apis.roblox.com/oauth/v1/token"; +const tokenRevocationEndpoint = "https://apis.roblox.com/oauth/v1/token/revoke"; -export class Roblox implements OAuth2ProviderWithPKCE { - private client: OAuth2Client; +export class Roblox { + private clientId: string; private clientSecret: string; + private redirectURI: string; constructor(clientId: string, clientSecret: string, redirectURI: string) { - this.client = new OAuth2Client(clientId, authorizeEndpoint, tokenEndpoint, { - redirectURI - }); + this.clientId = clientId; this.clientSecret = clientSecret; + this.redirectURI = redirectURI; } - public async createAuthorizationURL( - state: string, - codeVerifier: string, - options?: { - scopes?: string[]; - } - ): Promise { - const scopes = options?.scopes ?? []; - return await this.client.createAuthorizationURL({ - state, - codeVerifier, - scopes: [...scopes, "openid"] - }); + public createAuthorizationURL(state: string, codeVerifier: string, scopes: string[]): URL { + const url = new URL(authorizationEndpoint); + url.searchParams.set("response_type", "code"); + url.searchParams.set("client_id", this.clientId); + url.searchParams.set("state", state); + url.searchParams.set("scope", scopes.join(" ")); + url.searchParams.set("redirect_uri", this.redirectURI); + const codeChallenge = createS256CodeChallenge(codeVerifier); + url.searchParams.set("code_challenge_method", "S256"); + url.searchParams.set("code_challenge", codeChallenge); + return url; } public async validateAuthorizationCode( code: string, codeVerifier: string - ): Promise { - const result = await this.client.validateAuthorizationCode(code, { - credentials: this.clientSecret, - codeVerifier, - authenticateWith: "request_body" // Roblox doesn't support HTTP basic auth - }); - const tokens: RobloxTokens = { - accessToken: result.access_token, - refreshToken: result.refresh_token, - accessTokenExpiresAt: createDate(new TimeSpan(result.expires_in, "s")), - idToken: result.id_token - }; + ): Promise { + const body = new URLSearchParams(); + body.set("grant_type", "authorization_code"); + body.set("code", code); + body.set("code_verifier", codeVerifier); + body.set("redirect_uri", this.redirectURI); + body.set("client_id", this.clientId); + body.set("client_secret", this.clientSecret); + const request = createOAuth2Request(tokenEndpoint, body); + const tokens = await sendTokenRequest(request); return tokens; } - public async refreshAccessToken(refreshToken: string): Promise { - const result = await this.client.refreshAccessToken(refreshToken, { - credentials: this.clientSecret - }); - const tokens: RobloxTokens = { - accessToken: result.access_token, - refreshToken: result.refresh_token, - accessTokenExpiresAt: createDate(new TimeSpan(result.expires_in, "s")), - idToken: result.id_token - }; + public async refreshAccessToken(refreshToken: string): Promise { + const body = new URLSearchParams(); + body.set("grant_type", "refresh_token"); + body.set("refresh_token", refreshToken); + body.set("client_id", this.clientId); + body.set("client_secret", this.clientSecret); + const request = createOAuth2Request(tokenEndpoint, body); + const tokens = await sendTokenRequest(request); return tokens; } -} - -interface TokenResponseBody { - access_token: string; - refresh_token: string; - expires_in: number; - id_token: string; -} -export interface RobloxTokens { - accessToken: string; - refreshToken: string; - accessTokenExpiresAt: Date; - idToken: string; + public async revokeToken(token: string): Promise { + const body = new URLSearchParams(); + body.set("token", token); + body.set("client_id", this.clientId); + body.set("client_secret", this.clientSecret); + const request = createOAuth2Request(tokenRevocationEndpoint, body); + await sendTokenRevocationRequest(request); + } } diff --git a/src/providers/salesforce.ts b/src/providers/salesforce.ts index 7dd72dfd..4d638d53 100644 --- a/src/providers/salesforce.ts +++ b/src/providers/salesforce.ts @@ -1,69 +1,77 @@ -import { OAuth2Client } from "oslo/oauth2"; +import { createS256CodeChallenge } from "../oauth2.js"; +import { + createOAuth2Request, + encodeBasicCredentials, + sendTokenRequest, + sendTokenRevocationRequest +} from "../request.js"; -import type { OAuth2ProviderWithPKCE } from "../index.js"; +import type { OAuth2Tokens } from "../oauth2.js"; -export class Salesforce implements OAuth2ProviderWithPKCE { - private client: OAuth2Client; - private clientSecret: string; +export class Salesforce { + private authorizationEndpoint: string; + private tokenEndpoint: string; + private tokenRevocationEndpoint: string; - constructor(clientId: string, clientSecret: string, redirectURI: string) { - const authorizeEndpoint = "https://login.salesforce.com/services/oauth2/authorize"; - const tokenEndpoint = "https://login.salesforce.com/services/oauth2/token"; + private clientId: string; + private clientSecret: string; + private redirectURI: string; - this.client = new OAuth2Client(clientId, authorizeEndpoint, tokenEndpoint, { - redirectURI - }); + constructor(domain: string, clientId: string, clientSecret: string, redirectURI: string) { + this.authorizationEndpoint = `https://${domain}/services/oauth2/authorize`; + this.tokenEndpoint = `https://${domain}/services/oauth2/token`; + this.tokenRevocationEndpoint = `https://${domain}/services/oauth2/revoke`; + this.clientId = clientId; this.clientSecret = clientSecret; + this.redirectURI = redirectURI; } - public async createAuthorizationURL( - state: string, - codeVerifier: string, - options?: { - scopes?: string[]; - } - ): Promise { - return await this.client.createAuthorizationURL({ - state, - codeVerifier, - scopes: options?.scopes ?? [] - }); + public createAuthorizationURL(state: string, codeVerifier: string, scopes: string[]): URL { + const url = new URL(this.authorizationEndpoint); + url.searchParams.set("response_type", "code"); + url.searchParams.set("client_id", this.clientId); + url.searchParams.set("state", state); + url.searchParams.set("scope", scopes.join(" ")); + url.searchParams.set("redirect_uri", this.redirectURI); + const codeChallenge = createS256CodeChallenge(codeVerifier); + url.searchParams.set("code_challenge_method", "S256"); + url.searchParams.set("code_challenge", codeChallenge); + return url; } public async validateAuthorizationCode( code: string, codeVerifier: string - ): Promise { - const result = await this.client.validateAuthorizationCode(code, { - credentials: this.clientSecret, - codeVerifier - }); - return { - accessToken: result.access_token, - refreshToken: result.refresh_token ?? null, - idToken: result.id_token - }; + ): Promise { + const body = new URLSearchParams(); + body.set("grant_type", "authorization_code"); + body.set("code", code); + body.set("code_verifier", codeVerifier); + body.set("redirect_uri", this.redirectURI); + const request = createOAuth2Request(this.tokenEndpoint, body); + const encodedCredentials = encodeBasicCredentials(this.clientId, this.clientSecret); + request.headers.set("Authorization", `Basic ${encodedCredentials}`); + const tokens = await sendTokenRequest(request); + return tokens; } - public async refreshAccessToken(refreshToken: string): Promise { - const result = await this.client.refreshAccessToken(refreshToken, { - credentials: this.clientSecret - }); - return { - accessToken: result.access_token, - refreshToken: result.refresh_token ?? null, - idToken: result.id_token - }; + public async refreshAccessToken(refreshToken: string): Promise { + const body = new URLSearchParams(); + body.set("grant_type", "refresh_token"); + body.set("refresh_token", refreshToken); + const request = createOAuth2Request(this.tokenEndpoint, body); + const encodedCredentials = encodeBasicCredentials(this.clientId, this.clientSecret); + request.headers.set("Authorization", `Basic ${encodedCredentials}`); + const tokens = await sendTokenRequest(request); + return tokens; } -} -interface TokenResponseBody { - access_token: string; - refresh_token?: string; - id_token: string; -} -export interface SalesforceTokens { - accessToken: string; - idToken: string; - refreshToken: string | null; + public async revokeToken(token: string): Promise { + const body = new URLSearchParams(); + body.set("token", token); + const request = createOAuth2Request(this.tokenRevocationEndpoint, body); + const encodedCredentials = encodeBasicCredentials(this.clientId, this.clientSecret); + request.headers.set("Authorization", `Basic ${encodedCredentials}`); + await sendTokenRevocationRequest(request); + } } diff --git a/src/providers/shikimori.ts b/src/providers/shikimori.ts index 5eff03a4..1584e9c1 100644 --- a/src/providers/shikimori.ts +++ b/src/providers/shikimori.ts @@ -1,62 +1,50 @@ -import { OAuth2Client } from "oslo/oauth2"; -import type { OAuth2Provider } from "../index.js"; +import { createOAuth2Request, sendTokenRequest } from "../request.js"; -const authorizeEndpoint = "https://shikimori.one/oauth/authorize"; +import type { OAuth2Tokens } from "../oauth2.js"; + +const authorizationEndpoint = "https://shikimori.one/oauth/authorize"; const tokenEndpoint = "https://shikimori.one/oauth/token"; -export class Shikimori implements OAuth2Provider { - private client: OAuth2Client; +export class Shikimori { + private clientId: string; private clientSecret: string; + private redirectURI: string; constructor(clientId: string, clientSecret: string, redirectURI: string) { - this.client = new OAuth2Client(clientId, authorizeEndpoint, tokenEndpoint, { - redirectURI - }); + this.clientId = clientId; this.clientSecret = clientSecret; + this.redirectURI = redirectURI; } - createAuthorizationURL(state: string, scopes?: string[]): Promise { - return this.client.createAuthorizationURL({ state, scopes }); + public createAuthorizationURL(state: string): URL { + const url = new URL(authorizationEndpoint); + url.searchParams.set("response_type", "code"); + url.searchParams.set("client_id", this.clientId); + url.searchParams.set("state", state); + url.searchParams.set("redirect_uri", this.redirectURI); + return url; } - async validateAuthorizationCode(code: string): Promise { - const result = await this.client.validateAuthorizationCode(code, { - authenticateWith: "request_body", - credentials: this.clientSecret - }); - - return { - accessToken: result.access_token, - refreshToken: result.refresh_token, - accessTokenExpiresAt: new Date((result.created_at + result.expires_in) * 1000) - }; + public async validateAuthorizationCode(code: string): Promise { + const body = new URLSearchParams(); + body.set("grant_type", "authorization_code"); + body.set("code", code); + body.set("redirect_uri", this.redirectURI); + body.set("client_id", this.clientId); + body.set("client_secret", this.clientSecret); + const request = createOAuth2Request(tokenEndpoint, body); + const tokens = await sendTokenRequest(request); + return tokens; } - async refreshAccessToken(refreshToken: string): Promise { - const result = await this.client.refreshAccessToken(refreshToken, { - authenticateWith: "request_body", - credentials: this.clientSecret - }); - - return { - accessToken: result.access_token, - refreshToken: result.refresh_token, - accessTokenExpiresAt: new Date((result.created_at + result.expires_in) * 1000) - }; + public async refreshAccessToken(refreshToken: string): Promise { + const body = new URLSearchParams(); + body.set("grant_type", "refresh_token"); + body.set("refresh_token", refreshToken); + body.set("client_id", this.clientId); + body.set("client_secret", this.clientSecret); + const request = createOAuth2Request(tokenEndpoint, body); + const tokens = await sendTokenRequest(request); + return tokens; } } - -interface TokenResponseBody { - access_token: string; - token_type: string; - expires_in: number; - refresh_token: string; - scope: string; - created_at: number; -} - -export interface ShikimoriTokens { - accessToken: string; - refreshToken: string; - accessTokenExpiresAt: Date; -} diff --git a/src/providers/slack.ts b/src/providers/slack.ts index 041f9ffc..04dfe9fc 100644 --- a/src/providers/slack.ts +++ b/src/providers/slack.ts @@ -1,53 +1,44 @@ -import { OAuth2Client } from "oslo/oauth2"; +import { createOAuth2Request, encodeBasicCredentials, sendTokenRequest } from "../request.js"; -import type { OAuth2Provider } from "../index.js"; +import type { OAuth2Tokens } from "../oauth2.js"; -const authorizeEndpoint = "https://slack.com/openid/connect/authorize"; +const authorizationEndpoint = "https://slack.com/openid/connect/authorize"; const tokenEndpoint = "https://slack.com/api/openid.connect.token"; -export class Slack implements OAuth2Provider { - private client: OAuth2Client; +export class Slack { + private clientId: string; private clientSecret: string; + private redirectURI: string | null; - constructor(clientId: string, clientSecret: string, redirectURI: string) { - this.client = new OAuth2Client(clientId, authorizeEndpoint, tokenEndpoint, { - redirectURI - }); + constructor(clientId: string, clientSecret: string, redirectURI: string | null) { + this.clientId = clientId; this.clientSecret = clientSecret; + this.redirectURI = redirectURI; } - public async createAuthorizationURL( - state: string, - options?: { - scopes?: string[]; + public createAuthorizationURL(state: string, scopes: string[]): URL { + const url = new URL(authorizationEndpoint); + url.searchParams.set("response_type", "code"); + url.searchParams.set("client_id", this.clientId); + url.searchParams.set("state", state); + url.searchParams.set("scope", scopes.join(" ")); + if (this.redirectURI !== null) { + url.searchParams.set("redirect_uri", this.redirectURI); } - ): Promise { - const scopes = options?.scopes ?? []; - return await this.client.createAuthorizationURL({ - state, - scopes: [...scopes, "openid"] - }); + return url; } - public async validateAuthorizationCode(code: string): Promise { - const result = await this.client.validateAuthorizationCode(code, { - credentials: this.clientSecret, - authenticateWith: "request_body" - }); - const tokens: SlackTokens = { - accessToken: result.access_token, - idToken: result.id_token - }; + public async validateAuthorizationCode(code: string): Promise { + const body = new URLSearchParams(); + body.set("grant_type", "authorization_code"); + body.set("code", code); + if (this.redirectURI !== null) { + body.set("redirect_uri", this.redirectURI); + } + const request = createOAuth2Request(tokenEndpoint, body); + const encodedCredentials = encodeBasicCredentials(this.clientId, this.clientSecret); + request.headers.set("Authorization", `Basic ${encodedCredentials}`); + const tokens = await sendTokenRequest(request); return tokens; } } - -interface TokenResponseBody { - access_token: string; - id_token: string; -} - -export interface SlackTokens { - accessToken: string; - idToken: string; -} diff --git a/src/providers/soundcloud.ts b/src/providers/soundcloud.ts new file mode 100644 index 00000000..f20a733e --- /dev/null +++ b/src/providers/soundcloud.ts @@ -0,0 +1,55 @@ +import { createS256CodeChallenge } from "../oauth2.js"; +import { createOAuth2Request, sendTokenRequest } from "../request.js"; + +import { type OAuth2Tokens } from "../oauth2.js"; + +const authorizationEndpoint = "https://secure.soundcloud.com/authorize"; +const tokenEndpoint = "https://secure.soundcloud.com/oauth/token"; + +export class SoundCloud { + private clientId: string; + private clientSecret: string; + private redirectURI: string; + + public constructor(clientId: string, clientSecret: string, redirectURI: string) { + this.clientId = clientId; + this.clientSecret = clientSecret; + this.redirectURI = redirectURI; + } + + public createAuthorizationURL(state: string, codeVerifier: string): URL { + const url = new URL(authorizationEndpoint); + url.searchParams.set("client_id", this.clientId); + url.searchParams.set("redirect_uri", this.redirectURI); + url.searchParams.set("response_type", "code"); + const codeChallenge = createS256CodeChallenge(codeVerifier); + url.searchParams.set("code_challenge", codeChallenge); + url.searchParams.set("code_challenge_method", "S256"); + url.searchParams.set("state", state); + return url; + } + + public async validateAuthorizationCode(code: string, codeVerifier: string): Promise { + const body = new URLSearchParams(); + body.set("grant_type", "authorization_code"); + body.set("client_id", this.clientId); + body.set("client_secret", this.clientSecret); + body.set("redirect_uri", this.redirectURI); + body.set("code_verifier", codeVerifier); + body.set("code", code); + const request = createOAuth2Request(tokenEndpoint, body); + const tokens = await sendTokenRequest(request); + return tokens; + } + + public async refreshAccessToken(refreshToken: string): Promise { + const body = new URLSearchParams(); + body.set("grant_type", "refresh_token"); + body.set("refresh_token", refreshToken); + body.set("client_id", this.clientId); + body.set("client_secret", this.clientSecret); + const request = createOAuth2Request(tokenEndpoint, body); + const tokens = await sendTokenRequest(request); + return tokens; + } +} diff --git a/src/providers/spotify.ts b/src/providers/spotify.ts index 47abce49..4742d98a 100644 --- a/src/providers/spotify.ts +++ b/src/providers/spotify.ts @@ -1,67 +1,51 @@ -import { TimeSpan, createDate } from "oslo"; -import { OAuth2Client } from "oslo/oauth2"; +import { createOAuth2Request, encodeBasicCredentials, sendTokenRequest } from "../request.js"; -import type { OAuth2Provider } from "../index.js"; +import type { OAuth2Tokens } from "../oauth2.js"; -const authorizeEndpoint = "https://accounts.spotify.com/authorize"; +const authorizationEndpoint = "https://accounts.spotify.com/authorize"; const tokenEndpoint = "https://accounts.spotify.com/api/token"; -export class Spotify implements OAuth2Provider { - private client: OAuth2Client; +export class Spotify { + private clientId: string; private clientSecret: string; + private redirectURI: string; constructor(clientId: string, clientSecret: string, redirectURI: string) { - this.client = new OAuth2Client(clientId, authorizeEndpoint, tokenEndpoint, { - redirectURI - }); + this.clientId = clientId; this.clientSecret = clientSecret; + this.redirectURI = redirectURI; } - public async createAuthorizationURL( - state: string, - options?: { - scopes?: string[]; - } - ): Promise { - return await this.client.createAuthorizationURL({ - state, - scopes: options?.scopes ?? [] - }); + public createAuthorizationURL(state: string, scopes: string[]): URL { + const url = new URL(authorizationEndpoint); + url.searchParams.set("response_type", "code"); + url.searchParams.set("client_id", this.clientId); + url.searchParams.set("state", state); + url.searchParams.set("scope", scopes.join(" ")); + url.searchParams.set("redirect_uri", this.redirectURI); + return url; } - public async validateAuthorizationCode(code: string): Promise { - const result = await this.client.validateAuthorizationCode(code, { - credentials: this.clientSecret - }); - const tokens: SpotifyTokens = { - accessToken: result.access_token, - refreshToken: result.refresh_token, - accessTokenExpiresAt: createDate(new TimeSpan(result.expires_in, "s")) - }; + public async validateAuthorizationCode(code: string): Promise { + const body = new URLSearchParams(); + body.set("grant_type", "authorization_code"); + body.set("code", code); + body.set("redirect_uri", this.redirectURI); + const request = createOAuth2Request(tokenEndpoint, body); + const encodedCredentials = encodeBasicCredentials(this.clientId, this.clientSecret); + request.headers.set("Authorization", `Basic ${encodedCredentials}`); + const tokens = await sendTokenRequest(request); return tokens; } - public async refreshAccessToken(refreshToken: string): Promise { - const result = await this.client.refreshAccessToken(refreshToken, { - credentials: this.clientSecret - }); - const tokens: SpotifyTokens = { - accessToken: result.access_token, - refreshToken: result.refresh_token, - accessTokenExpiresAt: createDate(new TimeSpan(result.expires_in, "s")) - }; + public async refreshAccessToken(refreshToken: string): Promise { + const body = new URLSearchParams(); + body.set("grant_type", "refresh_token"); + body.set("refresh_token", refreshToken); + const request = createOAuth2Request(tokenEndpoint, body); + const encodedCredentials = encodeBasicCredentials(this.clientId, this.clientSecret); + request.headers.set("Authorization", `Basic ${encodedCredentials}`); + const tokens = await sendTokenRequest(request); return tokens; } } - -interface TokenResponseBody { - access_token: string; - expires_in: number; - refresh_token: string; -} - -export interface SpotifyTokens { - accessToken: string; - refreshToken: string; - accessTokenExpiresAt: Date; -} diff --git a/src/providers/strava.ts b/src/providers/strava.ts index a017ab0e..c970571a 100644 --- a/src/providers/strava.ts +++ b/src/providers/strava.ts @@ -1,69 +1,51 @@ -import { OAuth2Client } from "oslo/oauth2"; -import { TimeSpan, createDate } from "oslo"; +import { createOAuth2Request, sendTokenRequest } from "../request.js"; -import type { OAuth2Provider } from "../index.js"; +import type { OAuth2Tokens } from "../oauth2.js"; -const authorizeEndpoint = "https://www.strava.com/oauth/authorize"; +const authorizationEndpoint = "https://www.strava.com/oauth/authorize"; const tokenEndpoint = "https://www.strava.com/oauth/token"; -export class Strava implements OAuth2Provider { - private client: OAuth2Client; +export class Strava { + private clientId: string; private clientSecret: string; + private redirectURI: string; constructor(clientId: string, clientSecret: string, redirectURI: string) { - this.client = new OAuth2Client(clientId, authorizeEndpoint, tokenEndpoint, { - redirectURI - }); + this.clientId = clientId; this.clientSecret = clientSecret; + this.redirectURI = redirectURI; } - public async createAuthorizationURL( - state: string, - options?: { - scopes?: string[]; - } - ): Promise { - return await this.client.createAuthorizationURL({ - state, - scopes: options?.scopes ?? [] - }); + public createAuthorizationURL(state: string, scopes: string[]): URL { + const url = new URL(authorizationEndpoint); + url.searchParams.set("response_type", "code"); + url.searchParams.set("client_id", this.clientId); + url.searchParams.set("state", state); + url.searchParams.set("scope", scopes.join(" ")); + url.searchParams.set("redirect_uri", this.redirectURI); + return url; } - public async validateAuthorizationCode(code: string): Promise { - const result = await this.client.validateAuthorizationCode(code, { - authenticateWith: "request_body", - credentials: this.clientSecret - }); - const tokens: StravaTokens = { - accessToken: result.access_token, - refreshToken: result.refresh_token, - accessTokenExpiresAt: createDate(new TimeSpan(result.expires_in, "s")) - }; + public async validateAuthorizationCode(code: string): Promise { + const body = new URLSearchParams(); + body.set("grant_type", "authorization_code"); + body.set("code", code); + body.set("redirect_uri", this.redirectURI); + body.set("client_id", this.clientId); + body.set("client_secret", this.clientSecret); + const request = createOAuth2Request(tokenEndpoint, body); + const tokens = await sendTokenRequest(request); return tokens; } - public async refreshAccessToken(refreshToken: string): Promise { - const result = await this.client.refreshAccessToken(refreshToken, { - authenticateWith: "request_body", - credentials: this.clientSecret - }); - const tokens: StravaTokens = { - accessToken: result.access_token, - refreshToken: result.refresh_token, - accessTokenExpiresAt: createDate(new TimeSpan(result.expires_in, "s")) - }; + public async refreshAccessToken(refreshToken: string): Promise { + const body = new URLSearchParams(); + body.set("grant_type", "refresh_token"); + body.set("refresh_token", refreshToken); + body.set("client_id", this.clientId); + body.set("client_secret", this.clientSecret); + const request = createOAuth2Request(tokenEndpoint, body); + const tokens = await sendTokenRequest(request); return tokens; } } - -interface TokenResponseBody { - access_token: string; - expires_in: number; - refresh_token: string; -} - -export interface StravaTokens { - accessToken: string; - refreshToken: string; - accessTokenExpiresAt: Date; -} diff --git a/src/providers/tiltify.ts b/src/providers/tiltify.ts index f6de1a78..c4027e1d 100644 --- a/src/providers/tiltify.ts +++ b/src/providers/tiltify.ts @@ -1,69 +1,51 @@ -import { TimeSpan, createDate } from "oslo"; -import { OAuth2Client } from "oslo/oauth2"; +import { createOAuth2Request, sendTokenRequest } from "../request.js"; -import type { OAuth2Provider } from "../index.js"; +import type { OAuth2Tokens } from "../oauth2.js"; -const authorizeEndpoint = "https://v5api.tiltify.com/oauth/authorizeze"; +const authorizationEndpoint = "https://v5api.tiltify.com/oauth/authorizeze"; const tokenEndpoint = "https://v5api.tiltify.com/oauth/token"; -export class Tiltify implements OAuth2Provider { - private client: OAuth2Client; +export class Tiltify { + private clientId: string; private clientSecret: string; + private redirectURI: string; constructor(clientId: string, clientSecret: string, redirectURI: string) { - this.client = new OAuth2Client(clientId, authorizeEndpoint, tokenEndpoint, { - redirectURI - }); + this.clientId = clientId; this.clientSecret = clientSecret; + this.redirectURI = redirectURI; } - public async createAuthorizationURL( - state: string, - options?: { - scopes?: string[]; - } - ): Promise { - return await this.client.createAuthorizationURL({ - state, - scopes: options?.scopes ?? [] - }); + public createAuthorizationURL(state: string, scopes: string[]): URL { + const url = new URL(authorizationEndpoint); + url.searchParams.set("response_type", "code"); + url.searchParams.set("client_id", this.clientId); + url.searchParams.set("state", state); + url.searchParams.set("scope", scopes.join(" ")); + url.searchParams.set("redirect_uri", this.redirectURI); + return url; } - public async validateAuthorizationCode(code: string): Promise { - const result = await this.client.validateAuthorizationCode(code, { - authenticateWith: "request_body", - credentials: this.clientSecret - }); - const tokens: TiltifyTokens = { - accessToken: result.access_token, - refreshToken: result.refresh_token, - accessTokenExpiresAt: createDate(new TimeSpan(result.expires_in, "s")) - }; + public async validateAuthorizationCode(code: string): Promise { + const body = new URLSearchParams(); + body.set("grant_type", "authorization_code"); + body.set("code", code); + body.set("redirect_uri", this.redirectURI); + body.set("client_id", this.clientId); + body.set("client_secret", this.clientSecret); + const request = createOAuth2Request(tokenEndpoint, body); + const tokens = await sendTokenRequest(request); return tokens; } - public async refreshAccessToken(refreshToken: string): Promise { - const result = await this.client.refreshAccessToken(refreshToken, { - authenticateWith: "request_body", - credentials: this.clientSecret - }); - const tokens: TiltifyTokens = { - accessToken: result.access_token, - refreshToken: result.refresh_token, - accessTokenExpiresAt: createDate(new TimeSpan(result.expires_in, "s")) - }; + public async refreshAccessToken(refreshToken: string): Promise { + const body = new URLSearchParams(); + body.set("grant_type", "refresh_token"); + body.set("refresh_token", refreshToken); + body.set("client_id", this.clientId); + body.set("client_secret", this.clientSecret); + const request = createOAuth2Request(tokenEndpoint, body); + const tokens = await sendTokenRequest(request); return tokens; } } - -interface TokenResponseBody { - access_token: string; - expires_in: number; - refresh_token: string; -} - -export interface TiltifyTokens { - accessToken: string; - refreshToken: string; - accessTokenExpiresAt: Date; -} diff --git a/src/providers/tumblr.ts b/src/providers/tumblr.ts index 3b090481..00d0b65b 100644 --- a/src/providers/tumblr.ts +++ b/src/providers/tumblr.ts @@ -1,76 +1,51 @@ -import { OAuth2Client } from "oslo/oauth2"; +import { createOAuth2Request, encodeBasicCredentials, sendTokenRequest } from "../request.js"; -import type { OAuth2Provider } from "../index.js"; -import { TimeSpan, createDate } from "oslo"; +import type { OAuth2Tokens } from "../oauth2.js"; -const authorizeEndpoint = "https://www.tumblr.com/oauth2/authorize"; +const authorizationEndpoint = "https://www.tumblr.com/oauth2/authorize"; const tokenEndpoint = "https://api.tumblr.com/v2/oauth2/token"; -export class Tumblr implements OAuth2Provider { - private client: OAuth2Client; +export class Tumblr { + private clientId: string; private clientSecret: string; + private redirectURI: string; - constructor( - clientId: string, - clientSecret: string, - options?: { - redirectURI?: string; - } - ) { - this.client = new OAuth2Client(clientId, authorizeEndpoint, tokenEndpoint, { - redirectURI: options?.redirectURI - }); + constructor(clientId: string, clientSecret: string, redirectURI: string) { + this.clientId = clientId; this.clientSecret = clientSecret; + this.redirectURI = redirectURI; } - public async createAuthorizationURL( - state: string, - options?: { - scopes?: string[]; - } - ): Promise { - const scopes = options?.scopes ?? []; - return await this.client.createAuthorizationURL({ - state, - scopes: [...scopes, "basic"] - }); + public createAuthorizationURL(state: string, scopes: string[]): URL { + const url = new URL(authorizationEndpoint); + url.searchParams.set("response_type", "code"); + url.searchParams.set("client_id", this.clientId); + url.searchParams.set("state", state); + url.searchParams.set("scope", scopes.join(" ")); + url.searchParams.set("redirect_uri", this.redirectURI); + return url; } - public async validateAuthorizationCode(code: string): Promise { - const result = await this.client.validateAuthorizationCode(code, { - credentials: this.clientSecret - }); - - const tokens: TumblrTokens = { - accessToken: result.access_token, - refreshToken: result.refresh_token, - accessTokenExpiresAt: createDate(new TimeSpan(result.expires_in, "s")) - }; + public async validateAuthorizationCode(code: string): Promise { + const body = new URLSearchParams(); + body.set("grant_type", "authorization_code"); + body.set("code", code); + body.set("redirect_uri", this.redirectURI); + const request = createOAuth2Request(tokenEndpoint, body); + const encodedCredentials = encodeBasicCredentials(this.clientId, this.clientSecret); + request.headers.set("Authorization", `Basic ${encodedCredentials}`); + const tokens = await sendTokenRequest(request); return tokens; } - public async refreshAccessToken(refreshToken: string): Promise { - const result = await this.client.refreshAccessToken(refreshToken, { - authenticateWith: "request_body", - credentials: this.clientSecret - }); - - const tokens: TumblrTokens = { - accessToken: result.access_token, - refreshToken: result.refresh_token, - accessTokenExpiresAt: createDate(new TimeSpan(result.expires_in, "s")) - }; + public async refreshAccessToken(refreshToken: string): Promise { + const body = new URLSearchParams(); + body.set("grant_type", "refresh_token"); + body.set("refresh_token", refreshToken); + const request = createOAuth2Request(tokenEndpoint, body); + const encodedCredentials = encodeBasicCredentials(this.clientId, this.clientSecret); + request.headers.set("Authorization", `Basic ${encodedCredentials}`); + const tokens = await sendTokenRequest(request); return tokens; } } - -interface TokenResponseBody { - access_token: string; - expires_in: number; - refresh_token: string; -} -export interface TumblrTokens { - accessToken: string; - accessTokenExpiresAt: Date; - refreshToken: string; -} diff --git a/src/providers/twitch.ts b/src/providers/twitch.ts index 40d7e295..1afa5169 100644 --- a/src/providers/twitch.ts +++ b/src/providers/twitch.ts @@ -1,69 +1,53 @@ -import { TimeSpan, createDate } from "oslo"; -import { OAuth2Client } from "oslo/oauth2"; +import { createOAuth2Request, sendTokenRequest } from "../request.js"; -import type { OAuth2Provider } from "../index.js"; +import type { OAuth2Tokens } from "../oauth2.js"; -const authorizeEndpoint = "https://id.twitch.tv/oauth2/authorize"; +const authorizationEndpoint = "https://id.twitch.tv/oauth2/authorize"; const tokenEndpoint = "https://id.twitch.tv/oauth2/token"; -export class Twitch implements OAuth2Provider { - private client: OAuth2Client; +export class Twitch { + private clientId: string; private clientSecret: string; + private redirectURI: string; constructor(clientId: string, clientSecret: string, redirectURI: string) { - this.client = new OAuth2Client(clientId, authorizeEndpoint, tokenEndpoint, { - redirectURI - }); + this.clientId = clientId; this.clientSecret = clientSecret; + this.redirectURI = redirectURI; } - public async createAuthorizationURL( - state: string, - options?: { - scopes?: string[]; - } - ): Promise { - return await this.client.createAuthorizationURL({ - state, - scopes: options?.scopes ?? [] - }); + public createAuthorizationURL(state: string, scopes: string[]): URL { + const url = new URL(authorizationEndpoint); + url.searchParams.set("response_type", "code"); + url.searchParams.set("client_id", this.clientId); + url.searchParams.set("state", state); + url.searchParams.set("scope", scopes.join(" ")); + url.searchParams.set("redirect_uri", this.redirectURI); + return url; } - public async validateAuthorizationCode(code: string): Promise { - const result = await this.client.validateAuthorizationCode(code, { - authenticateWith: "request_body", - credentials: this.clientSecret - }); - const tokens: TwitchTokens = { - accessToken: result.access_token, - refreshToken: result.refresh_token, - accessTokenExpiresAt: createDate(new TimeSpan(result.expires_in, "s")) - }; + public async validateAuthorizationCode(code: string): Promise { + const body = new URLSearchParams(); + body.set("grant_type", "authorization_code"); + body.set("code", code); + body.set("redirect_uri", this.redirectURI); + body.set("client_id", this.clientId); + body.set("client_secret", this.clientSecret); + const request = createOAuth2Request(tokenEndpoint, body); + const tokens = await sendTokenRequest(request); return tokens; } - public async refreshAccessToken(refreshToken: string): Promise { - const result = await this.client.refreshAccessToken(refreshToken, { - authenticateWith: "request_body", - credentials: this.clientSecret - }); - const tokens: TwitchTokens = { - accessToken: result.access_token, - refreshToken: result.refresh_token, - accessTokenExpiresAt: createDate(new TimeSpan(result.expires_in, "s")) - }; + public async refreshAccessToken(refreshToken: string): Promise { + const body = new URLSearchParams(); + body.set("grant_type", "refresh_token"); + body.set("refresh_token", refreshToken); + body.set("client_id", this.clientId); + body.set("client_secret", this.clientSecret); + const request = createOAuth2Request(tokenEndpoint, body); + const tokens = await sendTokenRequest(request); return tokens; } -} - -interface TokenResponseBody { - access_token: string; - expires_in: number; - refresh_token: string; -} -export interface TwitchTokens { - accessToken: string; - refreshToken: string; - accessTokenExpiresAt: Date; + // No token revocation since the error responses are not spec-compliant } diff --git a/src/providers/twitter.ts b/src/providers/twitter.ts index 22a898c6..16297d62 100644 --- a/src/providers/twitter.ts +++ b/src/providers/twitter.ts @@ -1,68 +1,74 @@ -import { OAuth2Client } from "oslo/oauth2"; +import { createS256CodeChallenge } from "../oauth2.js"; +import { + createOAuth2Request, + encodeBasicCredentials, + sendTokenRequest, + sendTokenRevocationRequest +} from "../request.js"; -import type { OAuth2ProviderWithPKCE } from "../index.js"; +import type { OAuth2Tokens } from "../oauth2.js"; -const authorizeEndpoint = "https://twitter.com/i/oauth2/authorize"; +const authorizationEndpoint = "https://twitter.com/i/oauth2/authorize"; const tokenEndpoint = "https://api.twitter.com/2/oauth2/token"; +const tokenRevocationEndpoint = "https://api.twitter.com/2/oauth2/revoke"; -export class Twitter implements OAuth2ProviderWithPKCE { - private client: OAuth2Client; +export class Twitter { + private clientId: string; private clientSecret: string; + private redirectURI: string; constructor(clientId: string, clientSecret: string, redirectURI: string) { - this.client = new OAuth2Client(clientId, authorizeEndpoint, tokenEndpoint, { - redirectURI - }); + this.clientId = clientId; this.clientSecret = clientSecret; + this.redirectURI = redirectURI; } - public async createAuthorizationURL( - state: string, - codeVerifier: string, - options?: { - scopes?: string[]; - } - ): Promise { - return await this.client.createAuthorizationURL({ - state, - codeVerifier, - scopes: options?.scopes ?? [] - }); + public createAuthorizationURL(state: string, codeVerifier: string, scopes: string[]): URL { + const url = new URL(authorizationEndpoint); + url.searchParams.set("response_type", "code"); + url.searchParams.set("client_id", this.clientId); + url.searchParams.set("state", state); + url.searchParams.set("scope", scopes.join(" ")); + url.searchParams.set("redirect_uri", this.redirectURI); + const codeChallenge = createS256CodeChallenge(codeVerifier); + url.searchParams.set("code_challenge_method", "S256"); + url.searchParams.set("code_challenge", codeChallenge); + return url; } public async validateAuthorizationCode( code: string, codeVerifier: string - ): Promise { - const result = await this.client.validateAuthorizationCode(code, { - credentials: this.clientSecret, - codeVerifier - }); - const tokens: TwitterTokens = { - accessToken: result.access_token, - refreshToken: result.refresh_token ?? null - }; + ): Promise { + const body = new URLSearchParams(); + body.set("grant_type", "authorization_code"); + body.set("code", code); + body.set("code_verifier", codeVerifier); + body.set("redirect_uri", this.redirectURI); + const request = createOAuth2Request(tokenEndpoint, body); + const encodedCredentials = encodeBasicCredentials(this.clientId, this.clientSecret); + request.headers.set("Authorization", `Basic ${encodedCredentials}`); + const tokens = await sendTokenRequest(request); return tokens; } - public async refreshAccessToken(refreshToken: string): Promise { - const result = await this.client.refreshAccessToken(refreshToken, { - credentials: this.clientSecret - }); - const tokens: TwitterTokens = { - accessToken: result.access_token, - refreshToken: result.refresh_token ?? null - }; + public async refreshAccessToken(refreshToken: string): Promise { + const body = new URLSearchParams(); + body.set("grant_type", "refresh_token"); + body.set("refresh_token", refreshToken); + const request = createOAuth2Request(tokenEndpoint, body); + const encodedCredentials = encodeBasicCredentials(this.clientId, this.clientSecret); + request.headers.set("Authorization", `Basic ${encodedCredentials}`); + const tokens = await sendTokenRequest(request); return tokens; } -} - -interface TokenResponseBody { - access_token: string; - refresh_token?: string; -} -export interface TwitterTokens { - accessToken: string; - refreshToken: string | null; + public async revokeToken(token: string): Promise { + const body = new URLSearchParams(); + body.set("token", token); + const request = createOAuth2Request(tokenRevocationEndpoint, body); + const encodedCredentials = encodeBasicCredentials(this.clientId, this.clientSecret); + request.headers.set("Authorization", `Basic ${encodedCredentials}`); + await sendTokenRevocationRequest(request); + } } diff --git a/src/providers/vk.ts b/src/providers/vk.ts index f81a8fba..27ad67eb 100644 --- a/src/providers/vk.ts +++ b/src/providers/vk.ts @@ -1,59 +1,40 @@ -import { OAuth2Client } from "oslo/oauth2"; -import { TimeSpan, createDate } from "oslo"; +import { createOAuth2Request, sendTokenRequest } from "../request.js"; -import type { OAuth2Provider } from "../index.js"; +import type { OAuth2Tokens } from "../oauth2.js"; -const authorizeEndpoint = "https://oauth.vk.com/authorize"; +const authorizationEndpoint = "https://oauth.vk.com/authorize"; const tokenEndpoint = "https://oauth.vk.com/access_token"; -export class VK implements OAuth2Provider { - private client: OAuth2Client; +export class VK { + private clientId: string; private clientSecret: string; + private redirectURI: string; constructor(clientId: string, clientSecret: string, redirectURI: string) { - this.client = new OAuth2Client(clientId, authorizeEndpoint, tokenEndpoint, { - redirectURI - }); + this.clientId = clientId; this.clientSecret = clientSecret; + this.redirectURI = redirectURI; } - public async createAuthorizationURL( - state: string, - options?: { - scopes?: string[]; - } - ): Promise { - return await this.client.createAuthorizationURL({ - state, - scopes: options?.scopes ?? [] - }); + public createAuthorizationURL(state: string, scopes: string[]): URL { + const url = new URL(authorizationEndpoint); + url.searchParams.set("response_type", "code"); + url.searchParams.set("client_id", this.clientId); + url.searchParams.set("state", state); + url.searchParams.set("scope", scopes.join(" ")); + url.searchParams.set("redirect_uri", this.redirectURI); + return url; } - public async validateAuthorizationCode(code: string): Promise { - const result = await this.client.validateAuthorizationCode(code, { - authenticateWith: "request_body", - credentials: this.clientSecret - }); - const tokens: VKTokens = { - accessToken: result.access_token, - accessTokenExpiresAt: createDate(new TimeSpan(result.expires_in, "s")), - userId: result.user_id, - email: result.email ?? null - }; + public async validateAuthorizationCode(code: string): Promise { + const body = new URLSearchParams(); + body.set("grant_type", "authorization_code"); + body.set("code", code); + body.set("redirect_uri", this.redirectURI); + body.set("client_id", this.clientId); + body.set("client_secret", this.clientSecret); + const request = createOAuth2Request(tokenEndpoint, body); + const tokens = await sendTokenRequest(request); return tokens; } } - -interface TokenResponseBody { - access_token: string; - expires_in: number; - user_id: string; - email?: string; -} - -export interface VKTokens { - accessToken: string; - accessTokenExpiresAt: Date; - userId: string; - email: string | null; -} diff --git a/src/providers/workos.ts b/src/providers/workos.ts index f4e69a06..28afaf08 100644 --- a/src/providers/workos.ts +++ b/src/providers/workos.ts @@ -1,39 +1,39 @@ -import { OAuth2Client } from "oslo/oauth2"; +import { createOAuth2Request, sendTokenRequest } from "../request.js"; -import type { OAuth2Provider } from "../index.js"; +import type { OAuth2Tokens } from "../oauth2.js"; -const authorizeEndpoint = "https://api.workos.com/sso/authorize"; +const authorizationEndpoint = "https://api.workos.com/sso/authorize"; const tokenEndpoint = "https://api.workos.com/sso/token"; -export class WorkOS implements OAuth2Provider { - private client: OAuth2Client; +export class WorkOS { + private clientId: string; private clientSecret: string; + private redirectURI: string; constructor(clientId: string, clientSecret: string, redirectURI: string) { - this.client = new OAuth2Client(clientId, authorizeEndpoint, tokenEndpoint, { - redirectURI - }); + this.clientId = clientId; this.clientSecret = clientSecret; + this.redirectURI = redirectURI; } - public async createAuthorizationURL(state: string): Promise { - return await this.client.createAuthorizationURL({ - state - }); + public createAuthorizationURL(state: string): URL { + const url = new URL(authorizationEndpoint); + url.searchParams.set("response_type", "code"); + url.searchParams.set("client_id", this.clientId); + url.searchParams.set("state", state); + url.searchParams.set("redirect_uri", this.redirectURI); + return url; } - public async validateAuthorizationCode(code: string): Promise { - const result = await this.client.validateAuthorizationCode(code, { - authenticateWith: "request_body", - credentials: this.clientSecret - }); - const tokens: WorkOSTokens = { - accessToken: result.access_token - }; + public async validateAuthorizationCode(code: string): Promise { + const body = new URLSearchParams(); + body.set("grant_type", "authorization_code"); + body.set("code", code); + body.set("redirect_uri", this.redirectURI); + body.set("client_id", this.clientId); + body.set("client_secret", this.clientSecret); + const request = createOAuth2Request(tokenEndpoint, body); + const tokens = await sendTokenRequest(request); return tokens; } } - -export interface WorkOSTokens { - accessToken: string; -} diff --git a/src/providers/yahoo.ts b/src/providers/yahoo.ts index 30e9dc07..1c5b77bd 100644 --- a/src/providers/yahoo.ts +++ b/src/providers/yahoo.ts @@ -1,72 +1,51 @@ -import { OAuth2Client } from "oslo/oauth2"; -import { TimeSpan, createDate } from "oslo"; +import { createOAuth2Request, encodeBasicCredentials, sendTokenRequest } from "../request.js"; -import type { OAuth2Provider } from "../index.js"; +import type { OAuth2Tokens } from "../oauth2.js"; -const authorizeEndpoint = "https://api.login.yahoo.com/oauth2/request_auth"; +const authorizationEndpoint = "https://api.login.yahoo.com/oauth2/request_auth"; const tokenEndpoint = "https://api.login.yahoo.com/oauth2/get_token"; -export class Yahoo implements OAuth2Provider { - private client: OAuth2Client; +export class Yahoo { + private clientId: string; private clientSecret: string; + private redirectURI: string; constructor(clientId: string, clientSecret: string, redirectURI: string) { - this.client = new OAuth2Client(clientId, authorizeEndpoint, tokenEndpoint, { - redirectURI - }); + this.clientId = clientId; this.clientSecret = clientSecret; + this.redirectURI = redirectURI; } - public async createAuthorizationURL( - state: string, - options?: { - scopes?: string[]; - } - ): Promise { - const scopes = options?.scopes ?? []; - return await this.client.createAuthorizationURL({ - state, - scopes: [...scopes, "openid"] - }); + public createAuthorizationURL(state: string, scopes: string[]): URL { + const url = new URL(authorizationEndpoint); + url.searchParams.set("response_type", "code"); + url.searchParams.set("client_id", this.clientId); + url.searchParams.set("state", state); + url.searchParams.set("scope", scopes.join(" ")); + url.searchParams.set("redirect_uri", this.redirectURI); + return url; } - public async validateAuthorizationCode(code: string): Promise { - const result = await this.client.validateAuthorizationCode(code, { - credentials: this.clientSecret - }); - const tokens: YahooTokens = { - accessToken: result.access_token, - accessTokenExpiresAt: createDate(new TimeSpan(result.expires_in, "s")), - refreshToken: result.refresh_token, - idToken: result.id_token - }; + public async validateAuthorizationCode(code: string): Promise { + const body = new URLSearchParams(); + body.set("grant_type", "authorization_code"); + body.set("code", code); + body.set("redirect_uri", this.redirectURI); + const request = createOAuth2Request(tokenEndpoint, body); + const encodedCredentials = encodeBasicCredentials(this.clientId, this.clientSecret); + request.headers.set("Authorization", `Basic ${encodedCredentials}`); + const tokens = await sendTokenRequest(request); return tokens; } - public async refreshAccessToken(refreshToken: string): Promise { - const result = await this.client.refreshAccessToken(refreshToken, { - credentials: this.clientSecret - }); - const tokens: YahooTokens = { - accessToken: result.access_token, - accessTokenExpiresAt: createDate(new TimeSpan(result.expires_in, "s")), - refreshToken: result.refresh_token, - idToken: result.id_token - }; + public async refreshAccessToken(refreshToken: string): Promise { + const body = new URLSearchParams(); + body.set("grant_type", "refresh_token"); + body.set("refresh_token", refreshToken); + const request = createOAuth2Request(tokenEndpoint, body); + const encodedCredentials = encodeBasicCredentials(this.clientId, this.clientSecret); + request.headers.set("Authorization", `Basic ${encodedCredentials}`); + const tokens = await sendTokenRequest(request); return tokens; } } - -interface TokenResponseBody { - access_token: string; - refresh_token: string; - id_token: string; - expires_in: number; -} - -export interface YahooTokens { - accessToken: string; - accessTokenExpiresAt: Date; - refreshToken: string | null; - idToken: string; -} diff --git a/src/providers/yandex.ts b/src/providers/yandex.ts index bb7a1e56..da7aacc2 100644 --- a/src/providers/yandex.ts +++ b/src/providers/yandex.ts @@ -1,73 +1,55 @@ -import { OAuth2Client } from "oslo/oauth2"; -import { TimeSpan, createDate } from "oslo"; +import { createOAuth2Request, encodeBasicCredentials, sendTokenRequest } from "../request.js"; -import type { OAuth2Provider } from "../index.js"; +import type { OAuth2Tokens } from "../oauth2.js"; -const authorizeEndpoint = "https://oauth.yandex.com/authorize"; +const authorizationEndpoint = "https://oauth.yandex.com/authorize"; const tokenEndpoint = "https://oauth.yandex.com/token"; -export class Yandex implements OAuth2Provider { - private client: OAuth2Client; +export class Yandex { + private clientId: string; private clientSecret: string; + private redirectURI: string; - constructor( - clientId: string, - clientSecret: string, - options?: { - redirectURI: string; - } - ) { - this.client = new OAuth2Client(clientId, authorizeEndpoint, tokenEndpoint, { - redirectURI: options?.redirectURI - }); + constructor(clientId: string, clientSecret: string, redirectURI: string) { + this.clientId = clientId; this.clientSecret = clientSecret; + this.redirectURI = redirectURI; } - public async createAuthorizationURL( - state: string, - options?: { - scopes?: string[]; - } - ): Promise { - return await this.client.createAuthorizationURL({ - state, - scopes: options?.scopes ?? [] - }); + public createAuthorizationURL(state: string, scopes: string[]): URL { + const url = new URL(authorizationEndpoint); + url.searchParams.set("response_type", "code"); + url.searchParams.set("client_id", this.clientId); + url.searchParams.set("state", state); + url.searchParams.set("scope", scopes.join(" ")); + url.searchParams.set("redirect_uri", this.redirectURI); + return url; } - public async validateAuthorizationCode(code: string): Promise { - const result = await this.client.validateAuthorizationCode(code, { - credentials: this.clientSecret - }); - const tokens: YandexTokens = { - accessToken: result.access_token, - refreshToken: result.refresh_token, - accessTokenExpiresAt: createDate(new TimeSpan(result.expires_in, "s")) - }; + public async validateAuthorizationCode( + code: string, + codeVerifier: string + ): Promise { + const body = new URLSearchParams(); + body.set("grant_type", "authorization_code"); + body.set("code", code); + body.set("code_verifier", codeVerifier); + body.set("redirect_uri", this.redirectURI); + const request = createOAuth2Request(tokenEndpoint, body); + const encodedCredentials = encodeBasicCredentials(this.clientId, this.clientSecret); + request.headers.set("Authorization", `Basic ${encodedCredentials}`); + const tokens = await sendTokenRequest(request); return tokens; } - public async refreshAccessToken(refreshToken: string): Promise { - const result = await this.client.refreshAccessToken(refreshToken, { - credentials: this.clientSecret - }); - const tokens: YandexTokens = { - accessToken: result.access_token, - refreshToken: result.refresh_token, - accessTokenExpiresAt: createDate(new TimeSpan(result.expires_in, "s")) - }; + public async refreshAccessToken(refreshToken: string): Promise { + const body = new URLSearchParams(); + body.set("grant_type", "refresh_token"); + body.set("refresh_token", refreshToken); + const request = createOAuth2Request(tokenEndpoint, body); + const encodedCredentials = encodeBasicCredentials(this.clientId, this.clientSecret); + request.headers.set("Authorization", `Basic ${encodedCredentials}`); + const tokens = await sendTokenRequest(request); return tokens; } } - -interface TokenResponseBody { - access_token: string; - expires_in: number; - refresh_token: string; -} - -export interface YandexTokens { - accessToken: string; - refreshToken: string; - accessTokenExpiresAt: Date; -} diff --git a/src/providers/zoom.ts b/src/providers/zoom.ts index c1d93b09..b09106d8 100644 --- a/src/providers/zoom.ts +++ b/src/providers/zoom.ts @@ -1,70 +1,74 @@ -import { TimeSpan, createDate } from "oslo"; -import { OAuth2Client } from "oslo/oauth2"; +import { createS256CodeChallenge } from "../oauth2.js"; +import { + createOAuth2Request, + encodeBasicCredentials, + sendTokenRequest, + sendTokenRevocationRequest +} from "../request.js"; -import type { OAuth2ProviderWithPKCE } from "../index.js"; +import type { OAuth2Tokens } from "../oauth2.js"; -const authorizeEndpoint = "https://zoom.us/oauth/authorize"; +const authorizationEndpoint = "https://zoom.us/oauth/authorize"; const tokenEndpoint = "https://zoom.us/oauth/token"; +const tokenRevocationEndpoint = "https://zoom.us/oauth/revoke"; -export class Zoom implements OAuth2ProviderWithPKCE { - private client: OAuth2Client; +export class Zoom { + private clientId: string; private clientSecret: string; + private redirectURI: string; constructor(clientId: string, clientSecret: string, redirectURI: string) { - this.client = new OAuth2Client(clientId, authorizeEndpoint, tokenEndpoint, { - redirectURI - }); + this.clientId = clientId; this.clientSecret = clientSecret; + this.redirectURI = redirectURI; } - public async createAuthorizationURL( - state: string, - codeVerifier: string, - options?: { - scopes?: string[]; - } - ): Promise { - return await this.client.createAuthorizationURL({ - state, - codeVerifier, - scopes: options?.scopes ?? [] - }); + public createAuthorizationURL(state: string, codeVerifier: string, scopes: string[]): URL { + const url = new URL(authorizationEndpoint); + url.searchParams.set("response_type", "code"); + url.searchParams.set("client_id", this.clientId); + url.searchParams.set("state", state); + url.searchParams.set("scope", scopes.join(" ")); + url.searchParams.set("redirect_uri", this.redirectURI); + const codeChallenge = createS256CodeChallenge(codeVerifier); + url.searchParams.set("code_challenge_method", "S256"); + url.searchParams.set("code_challenge", codeChallenge); + return url; } - public async validateAuthorizationCode(code: string, codeVerifier: string): Promise { - const result = await this.client.validateAuthorizationCode(code, { - credentials: this.clientSecret, - codeVerifier - }); - const tokens: ZoomTokens = { - accessToken: result.access_token, - refreshToken: result.refresh_token, - accessTokenExpiresAt: createDate(new TimeSpan(result.expires_in, "s")) - }; + public async validateAuthorizationCode( + code: string, + codeVerifier: string + ): Promise { + const body = new URLSearchParams(); + body.set("grant_type", "authorization_code"); + body.set("code", code); + body.set("code_verifier", codeVerifier); + body.set("redirect_uri", this.redirectURI); + const request = createOAuth2Request(tokenEndpoint, body); + const encodedCredentials = encodeBasicCredentials(this.clientId, this.clientSecret); + request.headers.set("Authorization", `Basic ${encodedCredentials}`); + const tokens = await sendTokenRequest(request); return tokens; } - public async refreshAccessToken(refreshToken: string): Promise { - const result = await this.client.refreshAccessToken(refreshToken, { - credentials: this.clientSecret - }); - const tokens: ZoomTokens = { - accessToken: result.access_token, - refreshToken: result.refresh_token, - accessTokenExpiresAt: createDate(new TimeSpan(result.expires_in, "s")) - }; + public async refreshAccessToken(refreshToken: string): Promise { + const body = new URLSearchParams(); + body.set("grant_type", "refresh_token"); + body.set("refresh_token", refreshToken); + const request = createOAuth2Request(tokenEndpoint, body); + const encodedCredentials = encodeBasicCredentials(this.clientId, this.clientSecret); + request.headers.set("Authorization", `Basic ${encodedCredentials}`); + const tokens = await sendTokenRequest(request); return tokens; } -} - -interface TokenResponseBody { - access_token: string; - refresh_token: string; - expires_in: number; -} -export interface ZoomTokens { - accessToken: string; - refreshToken: string; - accessTokenExpiresAt: Date; + public async revokeToken(token: string): Promise { + const body = new URLSearchParams(); + body.set("token", token); + const request = createOAuth2Request(tokenRevocationEndpoint, body); + const encodedCredentials = encodeBasicCredentials(this.clientId, this.clientSecret); + request.headers.set("Authorization", `Basic ${encodedCredentials}`); + await sendTokenRevocationRequest(request); + } } diff --git a/src/request.ts b/src/request.ts new file mode 100644 index 00000000..7ae4f238 --- /dev/null +++ b/src/request.ts @@ -0,0 +1,109 @@ +import { base64 } from "@oslojs/encoding"; +import { OAuth2Tokens } from "./oauth2.js"; +import { OAuth2RequestResult } from "@oslojs/oauth2"; + +export function createOAuth2Request(endpoint: string, body: URLSearchParams): Request { + const request = new Request(endpoint, { + method: "POST", + body + }); + request.headers.set("Content-Type", "application/x-www-form-urlencoded"); + request.headers.set("Accept", "application/json"); + request.headers.set("User-Agent", "arctic"); + return request; +} + +export function encodeBasicCredentials(username: string, password: string): string { + const bytes = new TextEncoder().encode(`${username}:${password}`); + return base64.encode(bytes); +} + +export async function sendTokenRequest(request: Request): Promise { + let response: Response; + try { + response = await fetch(request); + } catch (e) { + throw new ArcticFetchError(e); + } + let data: unknown; + try { + data = await response.json(); + } catch { + throw new Error("Failed to parse response body"); + } + if (typeof data !== "object" || data === null) { + throw new Error("Unexpected response body data"); + } + const result = new OAuth2RequestResult(data); + if (result.hasErrorCode()) { + const error = createOAuth2RequestError(result); + throw error; + } + return new OAuth2Tokens(data); +} + +export async function sendTokenRevocationRequest(request: Request): Promise { + let response: Response; + try { + response = await fetch(request); + } catch (e) { + throw new ArcticFetchError(e); + } + if (response.ok && response.body === null) { + return; + } + let data: unknown; + try { + data = await response.json(); + } catch { + throw new Error("Failed to parse response body"); + } + if (typeof data !== "object" || data === null) { + throw new Error("Unexpected response body data"); + } + const result = new OAuth2RequestResult(data); + if (result.hasErrorCode()) { + const error = createOAuth2RequestError(result); + throw error; + } +} + +function createOAuth2RequestError(result: OAuth2RequestResult): OAuth2RequestError { + const code = result.errorCode(); + let description: string | null = null; + let uri: string | null = null; + let state: string | null = null; + if (result.hasErrorDescription()) { + description = result.errorDescription(); + } + if (result.hasErrorURI()) { + uri = result.errorURI(); + } + if ("state" in result.body && typeof result.body.state === "string") { + state = result.state(); + } + return new OAuth2RequestError(code, description, uri, state); +} + +export class ArcticFetchError extends Error { + constructor(cause: unknown) { + super("Failed to send request", { + cause + }); + } +} + +export class OAuth2RequestError extends Error { + public code: string; + public description: string | null; + public uri: string | null; + public state: string | null; + + constructor(code: string, description: string | null, uri: string | null, state: string | null) { + super(`OAuth request error: ${code}`); + this.code = code; + this.description = description; + this.uri = uri; + this.state = state; + } +} diff --git a/tsconfig.json b/tsconfig.json index 19a7c161..b01c0ee9 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,6 +1,8 @@ { "include": ["src"], "compilerOptions": { + "outDir": "dist", + "declaration": true, "esModuleInterop": true, "skipLibCheck": true, "target": "es2022", @@ -9,11 +11,8 @@ "resolveJsonModule": true, "moduleDetection": "force", "strict": true, - "noUncheckedIndexedAccess": true, "moduleResolution": "NodeNext", - "module": "NodeNext", - "outDir": "dist", - "declaration": true + "module": "NodeNext" }, "exclude": ["src/**/*.test.ts"] }