diff --git a/.auri/$f2eyvqe1.md b/.auri/$f2eyvqe1.md new file mode 100644 index 000000000..c24874121 --- /dev/null +++ b/.auri/$f2eyvqe1.md @@ -0,0 +1,6 @@ +--- +package: "@lucia-auth/oauth" # package name +type: "minor" # "major", "minor", "patch" +--- + +Add `twitter()` provider (OAuth 2.0 with PKCE) \ No newline at end of file diff --git a/.auri/$inc6a12a.md b/.auri/$inc6a12a.md new file mode 100644 index 000000000..bba1ed5a4 --- /dev/null +++ b/.auri/$inc6a12a.md @@ -0,0 +1,6 @@ +--- +package: "@lucia-auth/oauth" # package name +type: "patch" # "major", "minor", "patch" +--- + +Fix `GithubUserAuth.githubTokens` not including refresh token \ No newline at end of file diff --git a/documentation/content/oauth/providers/twitter.md b/documentation/content/oauth/providers/twitter.md new file mode 100644 index 000000000..793e09eb9 --- /dev/null +++ b/documentation/content/oauth/providers/twitter.md @@ -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; +``` + +##### 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; +}; +``` diff --git a/documentation/src/components/menus/OAuthMenu.astro b/documentation/src/components/menus/OAuthMenu.astro index 72d0d3c70..75253cf8d 100644 --- a/documentation/src/components/menus/OAuthMenu.astro +++ b/documentation/src/components/menus/OAuthMenu.astro @@ -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"] ] } ]} diff --git a/packages/oauth/src/providers/apple.ts b/packages/oauth/src/providers/apple.ts index b53f95acb..5b2dfd973 100644 --- a/packages/oauth/src/providers/apple.ts +++ b/packages/oauth/src/providers/apple.ts @@ -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 => { const clientSecret = await createSecretId({ certificate: config.certificate, teamId: config.teamId, @@ -83,15 +54,6 @@ export const apple = <_Auth extends Auth>(auth: _Auth, config: Config) => { }; }; - const getAppleUser = (idToken: string): AppleUser => { - const jwtPayload = decodeIdToken(idToken); - return { - email: jwtPayload.email, - email_verified: jwtPayload.email_verified, - sub: jwtPayload.sub - }; - }; - return { getAuthorizationUrl: async () => { return await createOAuth2AuthorizationUrl( @@ -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 => { + 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(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; diff --git a/packages/oauth/src/providers/auth0.ts b/packages/oauth/src/providers/auth0.ts index b02c00f35..853bfb89c 100644 --- a/packages/oauth/src/providers/auth0.ts +++ b/packages/oauth/src/providers/auth0.ts @@ -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(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 ?? []; @@ -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, @@ -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(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; diff --git a/packages/oauth/src/providers/discord.ts b/packages/oauth/src/providers/discord.ts index 73e5d84bb..eee24d39b 100644 --- a/packages/oauth/src/providers/discord.ts +++ b/packages/oauth/src/providers/discord.ts @@ -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 => { const tokens = await validateOAuth2AuthorizationCode<{ access_token: string; expires_in: number; @@ -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(request); - return discordUser; - }; - return { getAuthorizationUrl: async () => { const scopeConfig = config.scope ?? []; @@ -77,6 +66,23 @@ export const discord = <_Auth extends Auth>(auth: _Auth, config: Config) => { } as const satisfies OAuthProvider; }; +const getDiscordUser = async (accessToken: string): Promise => { + // 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(request); + return discordUser; +}; + +type DiscordTokens = { + accessToken: string; + refreshToken: string; + accessTokenExpiresIn: number; +}; + export type DiscordUser = { id: string; username: string; diff --git a/packages/oauth/src/providers/facebook.ts b/packages/oauth/src/providers/facebook.ts index 302861c96..1e01d8cfe 100644 --- a/packages/oauth/src/providers/facebook.ts +++ b/packages/oauth/src/providers/facebook.ts @@ -15,7 +15,7 @@ type Config = OAuthConfig & { const PROVIDER_ID = "facebook"; export const facebook = <_Auth extends Auth>(auth: _Auth, config: Config) => { - const getFacebookTokens = async (code: string) => { + const getFacebookTokens = async (code: string): Promise => { const tokens = await validateOAuth2AuthorizationCode<{ access_token: string; expires_in: number; @@ -36,20 +36,6 @@ export const facebook = <_Auth extends Auth>(auth: _Auth, config: Config) => { }; }; - const getFacebookUser = async (accessToken: string) => { - const requestUrl = createUrl("https://graph.facebook.com/me", { - access_token: accessToken, - fields: ["id", "name", "picture"].join(",") - }); - const request = new Request(requestUrl, { - headers: { - Authorization: authorizationHeader("bearer", accessToken) - } - }); - const facebookUser = await handleRequest(request); - return facebookUser; - }; - return { getAuthorizationUrl: async () => { return await createOAuth2AuthorizationUrl( @@ -79,6 +65,26 @@ export const facebook = <_Auth extends Auth>(auth: _Auth, config: Config) => { } as const satisfies OAuthProvider; }; +const getFacebookUser = async (accessToken: string): Promise => { + const requestUrl = createUrl("https://graph.facebook.com/me", { + access_token: accessToken, + fields: ["id", "name", "picture"].join(",") + }); + const request = new Request(requestUrl, { + headers: { + Authorization: authorizationHeader("bearer", accessToken) + } + }); + const facebookUser = await handleRequest(request); + return facebookUser; +}; + +type FacebookTokens = { + accessToken: string; + refreshToken: string; + accessTokenExpiresIn: number; +}; + export type FacebookUser = { id: string; name: string; diff --git a/packages/oauth/src/providers/github.ts b/packages/oauth/src/providers/github.ts index fb821daf7..44ac650d7 100644 --- a/packages/oauth/src/providers/github.ts +++ b/packages/oauth/src/providers/github.ts @@ -10,46 +10,33 @@ import type { OAuthConfig, OAuthProvider } from "../core.js"; const PROVIDER_ID = "github"; -type Tokens = - | { - accessToken: string; - accessTokenExpiresIn: null; - } - | { - accessToken: string; - accessTokenExpiresIn: number; - refreshToken: string; - refreshTokenExpiresIn: number; - }; - type Config = OAuthConfig & { redirectUri?: string; }; export const github = <_Auth extends Auth>(auth: _Auth, config: Config) => { - const getGithubTokens = async (code: string): Promise => { - type ResponseBody = - | { - access_token: string; - } - | { - access_token: string; - refresh_token: string; - expires_in: number; - refresh_token_expires_in: number; - }; - - const tokens = await validateOAuth2AuthorizationCode( - code, - "https://github.com/login/oauth/access_token", - { - clientId: config.clientId, - clientPassword: { - clientSecret: config.clientSecret, - authenticateWith: "client_secret" + const getGithubTokens = async (code: string): Promise => { + const tokens = + await validateOAuth2AuthorizationCode( + code, + "https://github.com/login/oauth/access_token", + { + clientId: config.clientId, + clientPassword: { + clientSecret: config.clientSecret, + authenticateWith: "client_secret" + } } - } - ); + ); + + if ("refresh_token" in tokens) { + return { + accessToken: tokens.access_token, + accessTokenExpiresIn: tokens.expires_in, + refreshToken: tokens.refresh_token, + refreshTokenExpiresIn: tokens.refresh_token_expires_in + }; + } return { accessToken: tokens.access_token, @@ -57,16 +44,6 @@ export const github = <_Auth extends Auth>(auth: _Auth, config: Config) => { }; }; - const getGithubUser = async (accessToken: string) => { - const request = new Request("https://api.github.com/user", { - headers: { - Authorization: authorizationHeader("bearer", accessToken) - } - }); - const githubUser = await handleRequest(request); - return githubUser; - }; - return { getAuthorizationUrl: async () => { return await createOAuth2AuthorizationUrl( @@ -96,6 +73,41 @@ export const github = <_Auth extends Auth>(auth: _Auth, config: Config) => { } as const satisfies OAuthProvider; }; +const getGithubUser = async (accessToken: string): Promise => { + const request = new Request("https://api.github.com/user", { + headers: { + Authorization: authorizationHeader("bearer", accessToken) + } + }); + const githubUser = await handleRequest(request); + return githubUser; +}; + +type AccessTokenResponseBody = + | { + access_token: string; + } + | { + access_token: string; + refresh_token: string; + expires_in: number; + refresh_token_expires_in: number; + }; + +type GithubTokens = + | { + accessToken: string; + accessTokenExpiresIn: null; + } + | { + accessToken: string; + accessTokenExpiresIn: number; + refreshToken: string; + refreshTokenExpiresIn: number; + }; + +export type GithubUser = PublicGithubUser | PrivateGithubUser; + type PublicGithubUser = { avatar_url: string; bio: string | null; @@ -150,5 +162,3 @@ type PrivateGithubUser = PublicGithubUser & { business_plus?: boolean; ldap_dn?: string; }; - -export type GithubUser = PublicGithubUser | PrivateGithubUser; diff --git a/packages/oauth/src/providers/google.ts b/packages/oauth/src/providers/google.ts index c9889c448..d622a1737 100644 --- a/packages/oauth/src/providers/google.ts +++ b/packages/oauth/src/providers/google.ts @@ -16,7 +16,7 @@ type Config = OAuthConfig & { const PROVIDER_ID = "google"; export const google = <_Auth extends Auth>(auth: _Auth, config: Config) => { - const getGoogleTokens = async (code: string) => { + const getGoogleTokens = async (code: string): Promise => { const tokens = await validateOAuth2AuthorizationCode<{ access_token: string; refresh_token?: string; @@ -37,19 +37,6 @@ export const google = <_Auth extends Auth>(auth: _Auth, config: Config) => { }; }; - const getGoogleUser = async (accessToken: string) => { - const request = new Request( - "https://www.googleapis.com/oauth2/v3/userinfo", - { - headers: { - Authorization: authorizationHeader("bearer", accessToken) - } - } - ); - const googleUser = await handleRequest(request); - return googleUser; - }; - return { getAuthorizationUrl: async () => { const scopeConfig = config.scope ?? []; @@ -86,6 +73,22 @@ export const google = <_Auth extends Auth>(auth: _Auth, config: Config) => { } as const satisfies OAuthProvider; }; +const getGoogleUser = async (accessToken: string): Promise => { + const request = new Request("https://www.googleapis.com/oauth2/v3/userinfo", { + headers: { + Authorization: authorizationHeader("bearer", accessToken) + } + }); + const googleUser = await handleRequest(request); + return googleUser; +}; + +type GoogleTokens = { + accessToken: string; + refreshToken: string | null; + accessTokenExpiresIn: number; +}; + export type GoogleUser = { sub: string; name: string; diff --git a/packages/oauth/src/providers/index.ts b/packages/oauth/src/providers/index.ts index 42489f339..08b11b76d 100644 --- a/packages/oauth/src/providers/index.ts +++ b/packages/oauth/src/providers/index.ts @@ -36,3 +36,6 @@ export type { SpotifyUser } from "./spotify.js"; export { twitch } from "./twitch.js"; export type { TwitchUser } from "./twitch.js"; + +export { twitter } from "./twitter.js"; +export type { TwitterUser } from "./twitter.js"; diff --git a/packages/oauth/src/providers/lichess.ts b/packages/oauth/src/providers/lichess.ts index 1fe0f4e50..dfddb5482 100644 --- a/packages/oauth/src/providers/lichess.ts +++ b/packages/oauth/src/providers/lichess.ts @@ -15,19 +15,10 @@ type Config = Omit & { const PROVIDER_ID = "lichess"; export const lichess = <_Auth extends Auth>(auth: _Auth, config: Config) => { - const getAuthorizationUrl = async () => { - return await createOAuth2AuthorizationUrlWithPKCE( - "https://lichess.org/oauth", - { - clientId: config.clientId, - codeChallengeMethod: "S256", - scope: config.scope ?? [], - redirectUri: config.redirectUri - } - ); - }; - - const getLichessTokens = async (code: string, codeVerifier: string) => { + const getLichessTokens = async ( + code: string, + codeVerifier: string + ): Promise => { const tokens = await validateOAuth2AuthorizationCode<{ access_token: string; expires_in: number; @@ -43,38 +34,51 @@ export const lichess = <_Auth extends Auth>(auth: _Auth, config: Config) => { }; }; - const getLichessUser = async (accessToken: string) => { - const request = new Request("https://lichess.org/api/account", { - headers: { - Authorization: authorizationHeader("bearer", accessToken) - } - }); - const lichessUser = await handleRequest(request); - return lichessUser; - }; - - const validateCallback = async (code: string, code_verifier: string) => { - const lichessTokens = await getLichessTokens(code, code_verifier); - const lichessUser = await getLichessUser(lichessTokens.accessToken); - const providerUserId = lichessUser.id; - const lichessUserAuth = await providerUserAuth( - auth, - PROVIDER_ID, - providerUserId - ); - return { - ...lichessUserAuth, - lichessUser, - lichessTokens - }; - }; - return { - getAuthorizationUrl, - validateCallback + getAuthorizationUrl: async () => { + return await createOAuth2AuthorizationUrlWithPKCE( + "https://lichess.org/oauth", + { + clientId: config.clientId, + codeChallengeMethod: "S256", + scope: config.scope ?? [], + redirectUri: config.redirectUri + } + ); + }, + validateCallback: async (code: string, code_verifier: string) => { + const lichessTokens = await getLichessTokens(code, code_verifier); + const lichessUser = await getLichessUser(lichessTokens.accessToken); + const providerUserId = lichessUser.id; + const lichessUserAuth = await providerUserAuth( + auth, + PROVIDER_ID, + providerUserId + ); + return { + ...lichessUserAuth, + lichessUser, + lichessTokens + }; + } } as const satisfies OAuthProvider; }; +const getLichessUser = async (accessToken: string): Promise => { + const request = new Request("https://lichess.org/api/account", { + headers: { + Authorization: authorizationHeader("bearer", accessToken) + } + }); + const lichessUser = await handleRequest(request); + return lichessUser; +}; + +type LichessTokens = { + accessToken: string; + accessTokenExpiresIn: number; +}; + export type LichessUser = { id: string; username: string; diff --git a/packages/oauth/src/providers/linkedin.ts b/packages/oauth/src/providers/linkedin.ts index 02a7c87d0..726599eb4 100644 --- a/packages/oauth/src/providers/linkedin.ts +++ b/packages/oauth/src/providers/linkedin.ts @@ -15,7 +15,7 @@ type Config = OAuthConfig & { }; export const linkedin = <_Auth extends Auth>(auth: _Auth, config: Config) => { - const getLinkedinTokens = async (code: string) => { + const getLinkedinTokens = async (code: string): Promise => { const tokens = await validateOAuth2AuthorizationCode<{ access_token: string; expires_in: number; @@ -40,40 +40,6 @@ export const linkedin = <_Auth extends Auth>(auth: _Auth, config: Config) => { }; }; - const getLinkedinUser = async (accessToken: string) => { - const linkedinUserProfile = await getProfile(accessToken); - const displayImageElement = linkedinUserProfile.profilePicture[ - "displayImage~" - ]?.elements - ?.slice(-1) - ?.pop(); - const linkedinUser: LinkedinUser = { - id: linkedinUserProfile.id, - firstName: linkedinUserProfile.localizedFirstName, - lastName: linkedinUserProfile.localizedLastName, - profilePicture: displayImageElement?.identifiers?.pop()?.identifier - }; - - return linkedinUser; - }; - - const getProfile = async ( - accessToken: string - ): Promise => { - const requestUrl = createUrl("https://api.linkedin.com/v2/me", { - projection: - "(id,localizedFirstName,localizedLastName,profilePicture(displayImage~:playableStreams))" - }); - - const request = new Request(requestUrl, { - headers: { - Authorization: authorizationHeader("bearer", accessToken) - } - }); - - return handleRequest(request); - }; - return { getAuthorizationUrl: async () => { const scopeConfig = config.scope ?? []; @@ -104,6 +70,40 @@ export const linkedin = <_Auth extends Auth>(auth: _Auth, config: Config) => { } as const satisfies OAuthProvider; }; +const getLinkedinUser = async (accessToken: string): Promise => { + const linkedinUserProfile = await getProfile(accessToken); + const displayImageElement = linkedinUserProfile.profilePicture[ + "displayImage~" + ]?.elements + ?.slice(-1) + ?.pop(); + const linkedinUser: LinkedinUser = { + id: linkedinUserProfile.id, + firstName: linkedinUserProfile.localizedFirstName, + lastName: linkedinUserProfile.localizedLastName, + profilePicture: displayImageElement?.identifiers?.pop()?.identifier + }; + + return linkedinUser; +}; + +const getProfile = async ( + accessToken: string +): Promise => { + const requestUrl = createUrl("https://api.linkedin.com/v2/me", { + projection: + "(id,localizedFirstName,localizedLastName,profilePicture(displayImage~:playableStreams))" + }); + + const request = new Request(requestUrl, { + headers: { + Authorization: authorizationHeader("bearer", accessToken) + } + }); + + return handleRequest(request); +}; + type LinkedinProfileResponse = { id: string; localizedFirstName: string; @@ -119,6 +119,14 @@ type LinkedinProfileResponse = { }; }; +type LinkedInTokens = { + accessToken: string; + accessTokenExpiresIn: number; + refreshToken: string; + refreshTokenExpiresIn: number; + scope: string; +}; + export type LinkedinUser = { id: string; firstName: string; diff --git a/packages/oauth/src/providers/osu.ts b/packages/oauth/src/providers/osu.ts index b6e19e4fa..43c9585ad 100644 --- a/packages/oauth/src/providers/osu.ts +++ b/packages/oauth/src/providers/osu.ts @@ -15,7 +15,7 @@ type Config = OAuthConfig & { const PROVIDER_ID = "osu"; export const osu = <_Auth extends Auth>(auth: _Auth, config: Config) => { - const getOsuTokens = async (code: string) => { + const getOsuTokens = async (code: string): Promise => { const tokens = await validateOAuth2AuthorizationCode<{ access_token: string; expires_in: number; @@ -37,16 +37,6 @@ export const osu = <_Auth extends Auth>(auth: _Auth, config: Config) => { }; }; - const getOsuUser = async (accessToken: string) => { - const request = new Request("https://osu.ppy.sh/api/v2/me/osu", { - headers: { - Authorization: authorizationHeader("bearer", accessToken) - } - }); - const osuUser = await handleRequest(request); - return osuUser; - }; - return { getAuthorizationUrl: async () => { const scopeConfig = config.scope ?? []; @@ -77,32 +67,20 @@ export const osu = <_Auth extends Auth>(auth: _Auth, config: Config) => { } as const satisfies OAuthProvider; }; -type OsuGameMode = "fruits" | "mania" | "osu" | "taiko"; +const getOsuUser = async (accessToken: string): Promise => { + const request = new Request("https://osu.ppy.sh/api/v2/me/osu", { + headers: { + Authorization: authorizationHeader("bearer", accessToken) + } + }); + const osuUser = await handleRequest(request); + return osuUser; +}; -type OsuUserStatistics = { - grade_counts: { - a: number; - s: number; - sh: number; - ss: number; - ssh: number; - }; - hit_accuracy: number; - is_ranked: boolean; - level: { - current: number; - progress: number; - }; - maximum_combo: number; - play_count: number; - play_time: number; - pp: number; - global_rank: number; - ranked_score: number; - replays_watched_by_others: number; - total_hits: number; - total_score: number; - country_rank: number; +type OsuTokens = { + accessToken: string; + refreshToken: string; + accessTokenExpiresIn: number; }; export type OsuUser = { @@ -227,3 +205,31 @@ export type OsuUser = { achievement_id: number; }[]; }; + +type OsuUserStatistics = { + grade_counts: { + a: number; + s: number; + sh: number; + ss: number; + ssh: number; + }; + hit_accuracy: number; + is_ranked: boolean; + level: { + current: number; + progress: number; + }; + maximum_combo: number; + play_count: number; + play_time: number; + pp: number; + global_rank: number; + ranked_score: number; + replays_watched_by_others: number; + total_hits: number; + total_score: number; + country_rank: number; +}; + +type OsuGameMode = "fruits" | "mania" | "osu" | "taiko"; diff --git a/packages/oauth/src/providers/patreon.ts b/packages/oauth/src/providers/patreon.ts index 865d0f7b0..003dc694e 100644 --- a/packages/oauth/src/providers/patreon.ts +++ b/packages/oauth/src/providers/patreon.ts @@ -16,7 +16,7 @@ type Config = OAuthConfig & { const PROVIDER_ID = "patreon"; export const patreon = <_Auth extends Auth>(auth: _Auth, config: Config) => { - const getPatreonTokens = async (code: string) => { + const getPatreonTokens = async (code: string): Promise => { const tokens = await validateOAuth2AuthorizationCode<{ access_token: string; refresh_token?: string; @@ -37,26 +37,6 @@ export const patreon = <_Auth extends Auth>(auth: _Auth, config: Config) => { }; }; - const getPatreonUser = async (accessToken: string) => { - const requestUrl = createUrl( - "https://www.patreon.com/api/oauth2/v2/identity", - { - "fields[user]": - "about,email,full_name,hide_pledges,image_url,is_email_verified,url" - } - ); - const request = new Request(requestUrl, { - headers: { - Authorization: authorizationHeader("bearer", accessToken) - } - }); - const { data: patreonUser } = await handleRequest<{ - data: PatreonUser; - }>(request); - - return patreonUser; - }; - return { getAuthorizationUrl: async () => { const scopeConfig = config.scope ?? []; @@ -87,6 +67,32 @@ export const patreon = <_Auth extends Auth>(auth: _Auth, config: Config) => { } as const satisfies OAuthProvider; }; +const getPatreonUser = async (accessToken: string): Promise => { + const requestUrl = createUrl( + "https://www.patreon.com/api/oauth2/v2/identity", + { + "fields[user]": + "about,email,full_name,hide_pledges,image_url,is_email_verified,url" + } + ); + const request = new Request(requestUrl, { + headers: { + Authorization: authorizationHeader("bearer", accessToken) + } + }); + const { data: patreonUser } = await handleRequest<{ + data: PatreonUser; + }>(request); + + return patreonUser; +}; + +type PatreonTokens = { + accessToken: string; + refreshToken: string | null; + accessTokenExpiresIn: number; +}; + export type PatreonUser = { id: string; attributes: { diff --git a/packages/oauth/src/providers/reddit.ts b/packages/oauth/src/providers/reddit.ts index 1f5e8e261..63556afcb 100644 --- a/packages/oauth/src/providers/reddit.ts +++ b/packages/oauth/src/providers/reddit.ts @@ -15,7 +15,7 @@ type Config = OAuthConfig & { const PROVIDER_ID = "reddit"; export const reddit = <_Auth extends Auth>(auth: _Auth, config: Config) => { - const getRedditTokens = async (code: string) => { + const getRedditTokens = async (code: string): Promise => { const tokens = await validateOAuth2AuthorizationCode<{ access_token: string; }>(code, "https://www.reddit.com/api/v1/access_token", { @@ -32,17 +32,6 @@ export const reddit = <_Auth extends Auth>(auth: _Auth, config: Config) => { }; }; - const getRedditUser = async (accessToken: string) => { - const request = new Request("https://oauth.reddit.com/api/v1/me", { - headers: { - Authorization: authorizationHeader("bearer", accessToken) - } - }); - const redditUser = await handleRequest(request); - - return redditUser; - }; - return { getAuthorizationUrl: async () => { return await createOAuth2AuthorizationUrl( @@ -75,6 +64,20 @@ export const reddit = <_Auth extends Auth>(auth: _Auth, config: Config) => { } as const satisfies OAuthProvider; }; +const getRedditUser = async (accessToken: string): Promise => { + const request = new Request("https://oauth.reddit.com/api/v1/me", { + headers: { + Authorization: authorizationHeader("bearer", accessToken) + } + }); + const redditUser = await handleRequest(request); + return redditUser; +}; + +type RedditTokens = { + accessToken: string; +}; + export type RedditUser = { is_employee: boolean; seen_layout_switch: boolean; diff --git a/packages/oauth/src/providers/spotify.ts b/packages/oauth/src/providers/spotify.ts index ebeb2724d..27000b3f3 100644 --- a/packages/oauth/src/providers/spotify.ts +++ b/packages/oauth/src/providers/spotify.ts @@ -16,7 +16,7 @@ type Config = OAuthConfig & { const PROVIDER_ID = "spotify"; export const spotify = <_Auth extends Auth>(auth: _Auth, config: Config) => { - const getSpotifyTokens = async (code: string) => { + const getSpotifyTokens = async (code: string): Promise => { const tokens = await validateOAuth2AuthorizationCode<{ access_token: string; token_type: string; @@ -41,16 +41,6 @@ export const spotify = <_Auth extends Auth>(auth: _Auth, config: Config) => { }; }; - const getSpotifyUser = async (accessToken: string) => { - // https://developer.spotify.com/documentation/web-api/reference/get-current-users-profile - const request = new Request("https://api.spotify.com/v1/me", { - headers: { - Authorization: authorizationHeader("bearer", accessToken) - } - }); - return handleRequest(request); - }; - return { getAuthorizationUrl: async () => { return await createOAuth2AuthorizationUrl( @@ -83,6 +73,24 @@ export const spotify = <_Auth extends Auth>(auth: _Auth, config: Config) => { } as const satisfies OAuthProvider; }; +const getSpotifyUser = async (accessToken: string): Promise => { + // https://developer.spotify.com/documentation/web-api/reference/get-current-users-profile + const request = new Request("https://api.spotify.com/v1/me", { + headers: { + Authorization: authorizationHeader("bearer", accessToken) + } + }); + return handleRequest(request); +}; + +type SpotifyTokens = { + accessToken: string; + tokenType: string; + scope: string; + accessTokenExpiresIn: number; + refreshToken: string; +}; + export type SpotifyUser = { country?: string; display_name: string | null; diff --git a/packages/oauth/src/providers/twitch.ts b/packages/oauth/src/providers/twitch.ts index ee68aa47f..d1cbf0b42 100644 --- a/packages/oauth/src/providers/twitch.ts +++ b/packages/oauth/src/providers/twitch.ts @@ -16,7 +16,7 @@ type Config = OAuthConfig & { const PROVIDER_ID = "twitch"; export const twitch = <_Auth extends Auth>(auth: _Auth, config: Config) => { - const getTwitchTokens = async (code: string) => { + const getTwitchTokens = async (code: string): Promise => { const tokens = await validateOAuth2AuthorizationCode<{ access_token: string; refresh_token: string; @@ -37,20 +37,6 @@ export const twitch = <_Auth extends Auth>(auth: _Auth, config: Config) => { }; }; - const getTwitchUser = async (accessToken: string) => { - // https://dev.twitch.tv/docs/api/reference/#get-users - const request = new Request("https://api.twitch.tv/helix/users", { - headers: { - "Client-ID": config.clientId, - Authorization: authorizationHeader("bearer", accessToken) - } - }); - const twitchUsersResponse = await handleRequest<{ - data: TwitchUser[]; - }>(request); - return twitchUsersResponse.data[0]; - }; - return { getAuthorizationUrl: async () => { const forceVerify = config.forceVerify ?? false; @@ -68,7 +54,10 @@ export const twitch = <_Auth extends Auth>(auth: _Auth, config: Config) => { }, validateCallback: async (code: string) => { const twitchTokens = await getTwitchTokens(code); - const twitchUser = await getTwitchUser(twitchTokens.accessToken); + const twitchUser = await getTwitchUser( + config.clientId, + twitchTokens.accessToken + ); const providerUserId = twitchUser.id; const twitchUserAuth = await providerUserAuth( auth, @@ -84,6 +73,29 @@ export const twitch = <_Auth extends Auth>(auth: _Auth, config: Config) => { } as const satisfies OAuthProvider; }; +const getTwitchUser = async ( + clientId: string, + accessToken: string +): Promise => { + // https://dev.twitch.tv/docs/api/reference/#get-users + const request = new Request("https://api.twitch.tv/helix/users", { + headers: { + "Client-ID": clientId, + Authorization: authorizationHeader("bearer", accessToken) + } + }); + const twitchUsersResponse = await handleRequest<{ + data: TwitchUser[]; + }>(request); + return twitchUsersResponse.data[0]; +}; + +type TwitchTokens = { + accessToken: string; + refreshToken: string; + accessTokenExpiresIn: number; +}; + export type TwitchUser = { id: string; login: string; diff --git a/packages/oauth/src/providers/twitter.ts b/packages/oauth/src/providers/twitter.ts new file mode 100644 index 000000000..d217b8ecf --- /dev/null +++ b/packages/oauth/src/providers/twitter.ts @@ -0,0 +1,98 @@ +import { + createOAuth2AuthorizationUrlWithPKCE, + providerUserAuth, + validateOAuth2AuthorizationCode +} from "../core.js"; +import { handleRequest, authorizationHeader } from "../request.js"; + +import type { Auth } from "lucia"; +import type { OAuthConfig, OAuthProvider } from "../core.js"; + +type Config = OAuthConfig & { + redirectUri: string; +}; + +const PROVIDER_ID = "twitter"; + +export const twitter = <_Auth extends Auth>(auth: _Auth, config: Config) => { + const getTwitterTokens = async ( + code: string, + codeVerifier: string + ): Promise => { + const currTimeInSeconds = Math.floor(Date.now() / 1000); + const tokens = await validateOAuth2AuthorizationCode<{ + access_token: string; + refresh_token?: string; + }>(code, "https://api.twitter.com/2/oauth2/token", { + clientId: config.clientId, + redirectUri: config.redirectUri, + codeVerifier, + clientPassword: { + authenticateWith: "http_basic_auth", + clientSecret: config.clientSecret + } + }); + + return { + accessToken: tokens.access_token, + accessTokenExpiresIn: currTimeInSeconds + 60 * 60 * 2 - 60, // 119 minutes + refreshToken: tokens.refresh_token ?? null + }; + }; + + return { + getAuthorizationUrl: async () => { + const scopeConfig = config.scope ?? []; + const [url, state, codeVerifier] = + await createOAuth2AuthorizationUrlWithPKCE( + "https://twitter.com/i/oauth2/authorize", + { + clientId: config.clientId, + codeChallengeMethod: "S256", + scope: ["tweet.read", "users.read", ...scopeConfig], + redirectUri: config.redirectUri + } + ); + return [url, codeVerifier, state] as const; + }, + validateCallback: async (code: string, code_verifier: string) => { + const twitterTokens = await getTwitterTokens(code, code_verifier); + const twitterUser = await getTwitterUser(twitterTokens.accessToken); + const providerUserId = twitterUser.id; + const twitterUserAuth = await providerUserAuth( + auth, + PROVIDER_ID, + providerUserId + ); + return { + ...twitterUserAuth, + twitterUser, + twitterTokens + }; + } + } as const satisfies OAuthProvider; +}; + +const getTwitterUser = async (accessToken: string): Promise => { + const request = new Request("https://api.twitter.com/2/users/me", { + headers: { + Authorization: authorizationHeader("bearer", accessToken) + } + }); + const twitterUserResult = await handleRequest<{ + data: TwitterUser; + }>(request); + return twitterUserResult.data; +}; + +type TwitterTokens = { + accessToken: string; + accessTokenExpiresIn: number; + refreshToken: string | null; +}; + +export type TwitterUser = { + id: string; + name: string; + username: string; +};