diff --git a/docs/pages/providers/zitadel.md b/docs/pages/providers/zitadel.md new file mode 100644 index 0000000..1cee227 --- /dev/null +++ b/docs/pages/providers/zitadel.md @@ -0,0 +1,144 @@ +--- +title: "Zitadel" +--- + +# Zitadel + +OAuth 2.0 provider for Zitadel. + +Also see the [Authenticate users with OpenID Connect](https://zitadel.com/docs/guides/integrate/login/oidc) (OIDC) guide. + +## Initialization + +After creating a project and a client, get the domain of your instance (if using Zitadel cloud) or your self-hosted deployment. + +The variable should include protocol (`http`, `https`) just in case you're using something like a local docker-compose instance. Please don't use `http` in production. + +The Zitadel provider is designed to be used in a server-side environment, with a configuration of a client that has Authentication as None. This is the recommended configuration for Zitadel web applications and backends. As a result, no secret client is required for the flows supported by the provider. + +```ts +import { Zitadel } from "arctic"; + +const domain = "https://xxxxxx.us1.zitadel.cloud"; +const zitadel = new Zitadel(domain, clientId, redirectURI); +``` + +## Create authorization URL + +```ts +import { generateState } from "arctic"; + +const state = arctic.generateState(); +const codeVerifier = arctic.generateCodeVerifier(); +const scopes = [ + // if you want the profile in the id token + "openid", + "profile", + "email", + // Required to get refresh token back in the response + "offline_access", + // required if you're going to use any other API endpoint + // for example: /auth/v1/users/me to get full user's profile + "urn:zitadel:iam:org:project:id:zitadel:aud", +]; +const authorizationUrl = zitadel.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). Zitadel returns an access token, the access token expiration, and a refresh token if you specified the `offline_access` scope. + +```ts +import { OAuth2RequestError, ArcticFetchError } from "arctic"; + +try { + const tokens = await zitadel.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. Zitadel 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 zitadel.refreshAccessToken(refreshToken); + 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 = zitadel.createAuthorizationURL(state, codeVerifier, scopes); +``` + +```ts +import { decodeIdToken } from "arctic"; + +const tokens = await zitadel.validateAuthorizationCode(code, codeVerifier); +const idToken = tokens.idToken(); +const claims = decodeIdToken(idToken); +``` + +### Get user profile + +With the right scopes, you can then fetch the user profile like this: + +```ts +const profileResponse = await fetch( + `${env.ZITADEL_URL}/auth/v1/users/me`, + { + headers: { + Accept: "application/json", + Authorization: `Bearer ${tokens.accessToken()}`, + }, + }, +); +``` + +## Revoke tokens + +Revoke tokens with `revokeToken()`. You should be able to revoke both the refresh and access token. It throws the same errors as `validateAuthorizationCode()`. + +```ts +try { + await box.revokeToken(refreshToken); +} catch (e) { + // Handle errors +} +``` diff --git a/src/index.ts b/src/index.ts index 9382986..9581b5a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -51,6 +51,7 @@ 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 { Zitadel } from "./providers/zitadel.js"; export { OAuth2Tokens, generateCodeVerifier, generateState } from "./oauth2.js"; export { OAuth2RequestError, ArcticFetchError } from "./request.js"; diff --git a/src/providers/zitadel.ts b/src/providers/zitadel.ts new file mode 100644 index 0000000..b820a21 --- /dev/null +++ b/src/providers/zitadel.ts @@ -0,0 +1,80 @@ +import { createS256CodeChallenge, type OAuth2Tokens } from "../oauth2.js"; +import { + createOAuth2Request, + sendTokenRequest, + sendTokenRevocationRequest, +} from "../request.js"; + +/** + * Zitadel is a class that provides a simple interface to interact with the Zitadel OAuth2 API. + * It's designed to be used in a server-side environment, such as a Node.js server with a configuration + * of a client that has Authentication as None. This is the recommended configuration for + * Zitadel web applications and backends. + * + * As a result, Unlike other oauth2 providers a client secret is not required. + */ +export class Zitadel { + private authorizationEndpoint: string; + private tokenEndpoint: string; + private tokenRevocationEndpoint: string; + + constructor( + domain: string, + readonly clientId: string, + readonly redirectURI: string, + ) { + this.authorizationEndpoint = `${domain}/oauth/v2/authorize`; + this.tokenEndpoint = `${domain}/oauth/v2/token`; + this.tokenRevocationEndpoint = `${domain}/oauth/v2/revoke`; + } + + 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 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); + const request = createOAuth2Request(this.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); + const request = createOAuth2Request(this.tokenEndpoint, body); + const tokens = await sendTokenRequest(request); + return tokens; + } + + public async revokeToken(token: string): Promise { + const body = new URLSearchParams(); + const endpoint = new URL(this.tokenRevocationEndpoint); + endpoint.searchParams.set("token", token); + endpoint.searchParams.set("client_id", this.clientId); + const request = createOAuth2Request(endpoint.toString(), body); + await sendTokenRevocationRequest(request); + } +}