Skip to content

Commit

Permalink
Feat: Add authentik provider (#120)
Browse files Browse the repository at this point in the history
* Add authentik provider

* add changesets

* Remove tokenType

Co-authored-by: pilcrow <pilcrowonpaper@gmail.com>

* Update authentik docs

Co-authored-by: pilcrow <pilcrowonpaper@gmail.com>

* Update authentik docs

Co-authored-by: pilcrow <pilcrowonpaper@gmail.com>

* Update src/providers/authentik.ts

Co-authored-by: pilcrow <pilcrowonpaper@gmail.com>

* removing token type and instance realmURL

* Updating the code block to be consistent with other providers

* Update authentik docs

* Update authentik.md

* Update authentik.ts

* Update doh0b.minor.md

* Update authentik.md

---------

Co-authored-by: pilcrow <pilcrowonpaper@gmail.com>
  • Loading branch information
asitanc and pilcrowonpaper authored May 22, 2024
1 parent e17432a commit 674b9ef
Show file tree
Hide file tree
Showing 8 changed files with 176 additions and 46 deletions.
1 change: 1 addition & 0 deletions .changesets/doh0b.minor.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Added Authentik auth provider
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,5 @@ pnpm-lock.yaml
dist
node_modules
.DS_Store
package-lock.json
package-lock.json
.history/
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ Arctic does not strictly follow semantic versioning. While we aim to only introd
- Apple
- Atlassian
- Auth0
- Authentik
- Bitbucket
- Box
- Coinbase
Expand Down
1 change: 1 addition & 0 deletions docs/malta.config.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
["Apple", "/providers/apple"],
["Atlassian", "/providers/atlassian"],
["Auth0", "/providers/auth0"],
["Authentik", "/providers/authentik"],
["Bitbucket", "/providers/bitbucket"],
["Box", "/providers/box"],
["Coinbase", "/providers/coinbase"],
Expand Down
48 changes: 48 additions & 0 deletions docs/pages/providers/authentik.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
---
title: "Authentik"
---

# Authentik

For usage, see [OAuth 2.0 provider with PKCE](/guides/oauth2-pkce).

```ts
import { Authentik } from "arctic";

const realmURL = "http://example.com";
const authentik = new Authentik(realmURL, clientId, clientSecret, redirectURI);
```

```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);
```

## Get refresh token

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).


```ts
const url: URL = await authentik.createAuthorizationURL(state, codeVerifier, {
scopes: ["profile", "email", "offline_access"]
});
```

## Get user profile

Authentik provides endpoint `/application/o/userinfo/` that you can use to fetch the user info once you obtain the Bearer token.

```ts
const tokens = await authentik.validateAuthorizationCode(code, codeVerifier);
const response = await fetch("https://example.com/application/o/userinfo/", {
headers: {
Authorization: `Bearer ${tokens.accessToken}`
}
});
const user = await response.json();
```
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ export { AniList } from "./providers/anilist.js";
export { Apple } from "./providers/apple.js";
export { Atlassian } from "./providers/atlassian.js";
export { Auth0 } from "./providers/auth0.js";
export { Authentik } from "./providers/authentik.js";
export { Bitbucket } from "./providers/bitbucket.js";
export { Box } from "./providers/box.js";
export { Coinbase } from "./providers/coinbase.js";
Expand Down Expand Up @@ -53,6 +54,7 @@ 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";
Expand Down
76 changes: 76 additions & 0 deletions src/providers/authentik.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { OAuth2Client } from "oslo/oauth2";
import { TimeSpan, createDate } from "oslo";
import type { OAuth2ProviderWithPKCE } from "../index.js";

export class Authentik implements OAuth2ProviderWithPKCE {
private client: OAuth2Client;
private clientSecret: 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
});
this.clientSecret = clientSecret;
}

public async createAuthorizationURL(
state: string,
codeVerifier: string,
options?: {
scopes?: string[];
}
): Promise<URL> {
const scopes = options?.scopes ?? [];
return await this.client.createAuthorizationURL({
state,
codeVerifier,
scopes: [...scopes, "openid"]
});
}

public async validateAuthorizationCode(
code: string,
codeVerifier: string
): Promise<AuthentikTokens> {
const result = await this.client.validateAuthorizationCode<TokenResponseBody>(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
};
return tokens;
}

public async refreshAccessToken(refreshToken: string): Promise<AuthentikTokens> {
const result = await this.client.refreshAccessToken<TokenResponseBody>(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
};
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;
}
90 changes: 45 additions & 45 deletions src/providers/shikimori.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,54 +5,54 @@ const authorizeEndpoint = "https://shikimori.one/oauth/authorize";
const tokenEndpoint = "https://shikimori.one/oauth/token";

export class Shikimori implements OAuth2Provider {
private client: OAuth2Client;
private clientSecret: string;

constructor(clientId: string, clientSecret: string, redirectURI: string) {
this.client = new OAuth2Client(clientId, authorizeEndpoint, tokenEndpoint, {
redirectURI
});
this.clientSecret = clientSecret;
}

createAuthorizationURL(state: string, scopes?: string[]): Promise<URL> {
return this.client.createAuthorizationURL({ state, scopes });
}
async validateAuthorizationCode(code: string): Promise<ShikimoriTokens> {
const result = await this.client.validateAuthorizationCode<TokenResponseBody>(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),
};
}

async refreshAccessToken(refreshToken: string): Promise<ShikimoriTokens> {
const result = await this.client.refreshAccessToken<TokenResponseBody>(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),
};
}
private client: OAuth2Client;
private clientSecret: string;

constructor(clientId: string, clientSecret: string, redirectURI: string) {
this.client = new OAuth2Client(clientId, authorizeEndpoint, tokenEndpoint, {
redirectURI
});
this.clientSecret = clientSecret;
}

createAuthorizationURL(state: string, scopes?: string[]): Promise<URL> {
return this.client.createAuthorizationURL({ state, scopes });
}

async validateAuthorizationCode(code: string): Promise<ShikimoriTokens> {
const result = await this.client.validateAuthorizationCode<TokenResponseBody>(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)
};
}

async refreshAccessToken(refreshToken: string): Promise<ShikimoriTokens> {
const result = await this.client.refreshAccessToken<TokenResponseBody>(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)
};
}
}

interface TokenResponseBody {
access_token: string;
token_type: string;
expires_in: number;
refresh_token: string;
scope: string;
created_at: number;
access_token: string;
token_type: string;
expires_in: number;
refresh_token: string;
scope: string;
created_at: number;
}

export interface ShikimoriTokens {
Expand Down

0 comments on commit 674b9ef

Please sign in to comment.