Skip to content

Commit

Permalink
Add Twitter OAuth 2.0 with PKCE provider (#990)
Browse files Browse the repository at this point in the history
  • Loading branch information
pilcrowonpaper authored Aug 18, 2023
1 parent 241f349 commit a65a03a
Show file tree
Hide file tree
Showing 19 changed files with 642 additions and 321 deletions.
6 changes: 6 additions & 0 deletions .auri/$f2eyvqe1.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
package: "@lucia-auth/oauth" # package name
type: "minor" # "major", "minor", "patch"
---

Add `twitter()` provider (OAuth 2.0 with PKCE)
6 changes: 6 additions & 0 deletions .auri/$inc6a12a.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
package: "@lucia-auth/oauth" # package name
type: "patch" # "major", "minor", "patch"
---

Fix `GithubUserAuth.githubTokens` not including refresh token
129 changes: 129 additions & 0 deletions documentation/content/oauth/providers/twitter.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
---
title: "Twitter"
description: "Learn how to use the Twitter OAuth provider"
---

OAuth integration for Twitter OAuth 2.0 with PKCE. The access token can only be used for Twitter API v2. Provider id is `twitter`.

```ts
import { twitter } from "@lucia-auth/oauth/providers";
import { auth } from "./lucia.js";

const twitterAuth = twitter(auth, config);
```

## `twitter()`

```ts
const twitter: (
auth: Auth,
config: {
clientId: string;
clientSecret: string;
redirectUri: string;
scope?: string[];
}
) => TwitterProvider;
```

##### Parameter

| name | type | description | optional |
| ------------------ | ------------------------------------------ | --------------------------------------- | :------: |
| auth | [`Auth`](/reference/lucia/interfaces/auth) | Lucia instance | |
| config.clientId | `string` | client id - choose any unique client id | |
| config.redirectUri | `string` | redirect URI | |
| config.scope | `string[]` | an array of scopes ||

##### Returns

| type | description |
| ------------------------------------- | ---------------- |
| [`TwitterProvider`](#twitterprovider) | Twitter provider |

## Interfaces

### `TwitterProvider`

Satisfies [`OAuthProvider`](/reference/oauth/oauthprovider).

#### `getAuthorizationUrl()`

Returns the authorization url for user redirection, a state and PKCE code verifier. The state and code verifier should be stored in a cookie and validated on callback.

```ts
const getAuthorizationUrl: () => Promise<
[url: URL, codeVerifier: string, state: string]
>;
```

##### Returns

| name | type | description |
| -------------- | -------- | -------------------- |
| `url` | `URL` | authorization url |
| `codeVerifier` | `string` | PKCE code verifier |
| `state` | `string` | state parameter used |

#### `validateCallback()`

Validates the callback code. Requires the PKCE code verifier generated with `getAuthorizationUrl()`.

```ts
const validateCallback: (
code: string,
codeVerifier: string
) => Promise<ProviderSession>;
```

##### Parameter

| name | type | description |
| -------------- | -------- | --------------------------------------------------------- |
| `code` | `string` | authorization code from callback |
| `codeVerifier` | `string` | PKCE code verifier generated with `getAuthorizationUrl()` |

##### Returns

| type |
| ------------------------------------- |
| [`TwitterUserAuth`](#twitteruserauth) |

##### Errors

Request errors are thrown as [`OAuthRequestError`](/reference/oauth/interfaces#oauthrequesterror).

### `TwitterUserAuth`

```ts
type TwitterUserAuth = ProviderUserAuth & {
twitterUser: TwitterUser;
twitterTokens: TwitterTokens;
};
```

| type |
| ------------------------------------------------------------------ |
| [`ProviderUserAuth`](/reference/oauth/interfaces#provideruserauth) |
| [`twitterUser`](#twitteruser) |
| [`twitterTokens`](#twittertokens) |

### `TwitterTokens`

```ts
type TwitterTokens = {
accessToken: string;
accessTokenExpiresIn: string;
refreshToken: string;
};
```

## `TwitterUser`

```ts
type TwitterUser = {
id: string;
name: string;
username: string;
};
```
3 changes: 2 additions & 1 deletion documentation/src/components/menus/OAuthMenu.astro
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,8 @@ import Menu from "./Menu.astro";
["Patreon", "/oauth/providers/patreon"],
["Reddit", "/oauth/providers/reddit"],
["Spotify", "/oauth/providers/spotify"],
["Twitch", "/oauth/providers/twitch"]
["Twitch", "/oauth/providers/twitch"],
["Twitter", "/oauth/providers/twitter"]
]
}
]}
Expand Down
81 changes: 42 additions & 39 deletions packages/oauth/src/providers/apple.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,36 +25,7 @@ const PROVIDER_ID = "apple";
const APPLE_AUD = "https://appleid.apple.com";

export const apple = <_Auth extends Auth>(auth: _Auth, config: Config) => {
const createSecretId = async ({
certificate,
teamId,
clientId,
keyId
}: AppleConfig & {
clientId: string;
}) => {
const now = Math.floor(Date.now() / 1000);
const payload = {
iss: teamId,
iat: now,
exp: now + 60 * 3,
aud: APPLE_AUD,
sub: clientId
};
const privateKey = getPKCS8Key(certificate);
const jwt = await createES256SignedJWT(
{
alg: "ES256",
kid: keyId
},
payload,
privateKey
);

return jwt;
};

const getAppleTokens = async (code: string) => {
const getAppleTokens = async (code: string): Promise<AppleTokens> => {
const clientSecret = await createSecretId({
certificate: config.certificate,
teamId: config.teamId,
Expand Down Expand Up @@ -83,15 +54,6 @@ export const apple = <_Auth extends Auth>(auth: _Auth, config: Config) => {
};
};

const getAppleUser = (idToken: string): AppleUser => {
const jwtPayload = decodeIdToken<AppleUser>(idToken);
return {
email: jwtPayload.email,
email_verified: jwtPayload.email_verified,
sub: jwtPayload.sub
};
};

return {
getAuthorizationUrl: async () => {
return await createOAuth2AuthorizationUrl(
Expand Down Expand Up @@ -125,6 +87,47 @@ export const apple = <_Auth extends Auth>(auth: _Auth, config: Config) => {
} as const satisfies OAuthProvider;
};

const createSecretId = async (
config: AppleConfig & {
clientId: string;
}
): Promise<string> => {
const now = Math.floor(Date.now() / 1000);
const payload = {
iss: config.teamId,
iat: now,
exp: now + 60 * 3,
aud: APPLE_AUD,
sub: config.clientId
};
const privateKey = getPKCS8Key(config.certificate);
const jwt = await createES256SignedJWT(
{
alg: "ES256",
kid: config.keyId
},
payload,
privateKey
);
return jwt;
};

const getAppleUser = (idToken: string): AppleUser => {
const jwtPayload = decodeIdToken<AppleUser>(idToken);
return {
email: jwtPayload.email,
email_verified: jwtPayload.email_verified,
sub: jwtPayload.sub
};
};

type AppleTokens = {
accessToken: string;
refreshToken: string | null;
accessTokenExpiresIn: number;
idToken: string;
};

export type AppleUser = {
email: string;
email_verified: boolean;
Expand Down
47 changes: 25 additions & 22 deletions packages/oauth/src/providers/auth0.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,27 +43,6 @@ export const auth0 = <_Auth extends Auth>(auth: _Auth, config: Config) => {
};
};

const getAuth0User = async (accessToken: string) => {
const request = new Request(new URL("/userinfo", config.appDomain), {
headers: {
Authorization: authorizationHeader("bearer", accessToken)
}
});

const auth0Profile = await handleRequest<Auth0Profile>(request);

const auth0User: Auth0User = {
sub: auth0Profile.sub,
id: auth0Profile.sub.split("|")[1],
nickname: auth0Profile.nickname,
name: auth0Profile.name,
picture: auth0Profile.picture,
updated_at: auth0Profile.updated_at
};

return auth0User;
};

return {
getAuthorizationUrl: async () => {
const scopeConfig = config.scope ?? [];
Expand All @@ -84,7 +63,10 @@ export const auth0 = <_Auth extends Auth>(auth: _Auth, config: Config) => {
},
validateCallback: async (code: string) => {
const auth0Tokens = await getAuth0Tokens(code);
const auth0User = await getAuth0User(auth0Tokens.accessToken);
const auth0User = await getAuth0User(
config.appDomain,
auth0Tokens.accessToken
);
const providerUserId = auth0User.id;
const auth0UserAuth = await providerUserAuth(
auth,
Expand All @@ -100,6 +82,27 @@ export const auth0 = <_Auth extends Auth>(auth: _Auth, config: Config) => {
} as const satisfies OAuthProvider;
};

const getAuth0User = async (appDomain: string, accessToken: string) => {
const request = new Request(new URL("/userinfo", appDomain), {
headers: {
Authorization: authorizationHeader("bearer", accessToken)
}
});

const auth0Profile = await handleRequest<Auth0Profile>(request);

const auth0User: Auth0User = {
sub: auth0Profile.sub,
id: auth0Profile.sub.split("|")[1],
nickname: auth0Profile.nickname,
name: auth0Profile.name,
picture: auth0Profile.picture,
updated_at: auth0Profile.updated_at
};

return auth0User;
};

type Auth0Profile = {
sub: string;
nickname: string;
Expand Down
30 changes: 18 additions & 12 deletions packages/oauth/src/providers/discord.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ type Config = OAuthConfig & {
const PROVIDER_ID = "discord";

export const discord = <_Auth extends Auth>(auth: _Auth, config: Config) => {
const getDiscordTokens = async (code: string) => {
const getDiscordTokens = async (code: string): Promise<DiscordTokens> => {
const tokens = await validateOAuth2AuthorizationCode<{
access_token: string;
expires_in: number;
Expand All @@ -36,17 +36,6 @@ export const discord = <_Auth extends Auth>(auth: _Auth, config: Config) => {
};
};

const getDiscordUser = async (accessToken: string) => {
// do not use oauth/users/@me because it ignores intents, use oauth/users/@me instead
const request = new Request("https://discord.com/api/users/@me", {
headers: {
Authorization: authorizationHeader("bearer", accessToken)
}
});
const discordUser = await handleRequest<DiscordUser>(request);
return discordUser;
};

return {
getAuthorizationUrl: async () => {
const scopeConfig = config.scope ?? [];
Expand Down Expand Up @@ -77,6 +66,23 @@ export const discord = <_Auth extends Auth>(auth: _Auth, config: Config) => {
} as const satisfies OAuthProvider;
};

const getDiscordUser = async (accessToken: string): Promise<DiscordUser> => {
// do not use oauth/users/@me because it ignores intents, use oauth/users/@me instead
const request = new Request("https://discord.com/api/users/@me", {
headers: {
Authorization: authorizationHeader("bearer", accessToken)
}
});
const discordUser = await handleRequest<DiscordUser>(request);
return discordUser;
};

type DiscordTokens = {
accessToken: string;
refreshToken: string;
accessTokenExpiresIn: number;
};

export type DiscordUser = {
id: string;
username: string;
Expand Down
Loading

0 comments on commit a65a03a

Please sign in to comment.