Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add zitadel provider #201

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
144 changes: 144 additions & 0 deletions docs/pages/providers/zitadel.md
Original file line number Diff line number Diff line change
@@ -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
}
```
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
80 changes: 80 additions & 0 deletions src/providers/zitadel.ts
Original file line number Diff line number Diff line change
@@ -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,
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you rename this to baseURL?

readonly clientId: string,
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I know it's a bit verbose, but can you manually define the clientId and redirectURI like the rest of the codebase?

this.clientId = clientId
this.redirectURI = redirectURI

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<OAuth2Tokens> {
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<OAuth2Tokens> {
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<void> {
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);
}
}