From dea1a8a5ebac4bc5c3577d1191753a000f2f4d7d Mon Sep 17 00:00:00 2001 From: justpeterpan Date: Sun, 24 Nov 2024 03:58:29 +0100 Subject: [PATCH 1/2] feat: add strava oauth provider --- README.md | 1 + playground/.env.example | 3 + playground/app.vue | 6 + playground/auth.d.ts | 1 + playground/server/routes/auth/strava.get.ts | 16 ++ src/module.ts | 8 +- src/runtime/server/lib/oauth/strava.ts | 223 ++++++++++++++++++++ src/runtime/types/oauth-config.ts | 2 +- 8 files changed, 258 insertions(+), 2 deletions(-) create mode 100644 playground/server/routes/auth/strava.get.ts create mode 100644 src/runtime/server/lib/oauth/strava.ts diff --git a/README.md b/README.md index 2294349..6faad32 100644 --- a/README.md +++ b/README.md @@ -224,6 +224,7 @@ It can also be set using environment variables: - Seznam - Spotify - Steam +- Strava - TikTok - Twitch - VK diff --git a/playground/.env.example b/playground/.env.example index 4cda106..7423af5 100644 --- a/playground/.env.example +++ b/playground/.env.example @@ -93,3 +93,6 @@ NUXT_OAUTH_ZITADEL_DOMAIN= NUXT_OAUTH_AUTHENTIK_CLIENT_ID= NUXT_OAUTH_AUTHENTIK_CLIENT_SECRET= NUXT_OAUTH_AUTHENTIK_DOMAIN= +# Strava +NUXT_OAUTH_STRAVA_CLIENT_ID= +NUXT_OAUTH_STRAVA_CLIENT_SECRET= diff --git a/playground/app.vue b/playground/app.vue index 761768c..ae36cd0 100644 --- a/playground/app.vue +++ b/playground/app.vue @@ -171,6 +171,12 @@ const providers = computed(() => disabled: Boolean(user.value?.seznam), icon: 'i-gravity-ui-lock', }, + { + label: user.value?.strava || 'Strava', + to: '/auth/strava', + disabled: Boolean(user.value?.strava), + icon: 'i-simple-icons-strava', + }, ].map(p => ({ ...p, prefetch: false, diff --git a/playground/auth.d.ts b/playground/auth.d.ts index 71b55c3..81d29b4 100644 --- a/playground/auth.d.ts +++ b/playground/auth.d.ts @@ -31,6 +31,7 @@ declare module '#auth-utils' { zitadel?: string authentik?: string seznam?: string + strava?: string } interface UserSession { diff --git a/playground/server/routes/auth/strava.get.ts b/playground/server/routes/auth/strava.get.ts new file mode 100644 index 0000000..7a7705e --- /dev/null +++ b/playground/server/routes/auth/strava.get.ts @@ -0,0 +1,16 @@ +export default defineOAuthStravaEventHandler({ + config: { + approvalPrompt: 'force', + scope: ['profile:read_all'], + }, + async onSuccess(event, { user }) { + await setUserSession(event, { + user: { + strava: `${user.firstname} ${user.lastname}`, + }, + loggedInAt: Date.now(), + }) + + return sendRedirect(event, '/') + }, +}) diff --git a/src/module.ts b/src/module.ts index 4e3bac9..f180b10 100644 --- a/src/module.ts +++ b/src/module.ts @@ -164,7 +164,7 @@ export default defineNuxtModule({ clientSecret: '', redirectURL: '', }) - // GitHub OAuth + // GitLab OAuth runtimeConfig.oauth.gitlab = defu(runtimeConfig.oauth.gitlab, { clientId: '', clientSecret: '', @@ -341,5 +341,11 @@ export default defineNuxtModule({ clientSecret: '', redirectURL: '', }) + // Strava OAuth + runtimeConfig.oauth.strava = defu(runtimeConfig.oauth.strava, { + clientId: '', + clientSecret: '', + redirectURL: '', + }) }, }) diff --git a/src/runtime/server/lib/oauth/strava.ts b/src/runtime/server/lib/oauth/strava.ts new file mode 100644 index 0000000..3c33a6c --- /dev/null +++ b/src/runtime/server/lib/oauth/strava.ts @@ -0,0 +1,223 @@ +import type { H3Event } from 'h3' +import { eventHandler, getQuery, sendRedirect } from 'h3' +import { withQuery } from 'ufo' +import { defu } from 'defu' +import { + getOAuthRedirectURL, + handleAccessTokenErrorResponse, + handleMissingConfiguration, + requestAccessToken, +} from '../utils' +import { useRuntimeConfig, createError } from '#imports' +import type { OAuthConfig } from '#auth-utils' + +export interface OAuthStravaConfig { + /** + * Strava OAuth Client ID + * @default process.env.NUXT_OAUTH_STRAVA_CLIENT_ID + */ + clientId?: string + + /** + * Strava OAuth Client Secret + * @default process.env.NUXT_OAUTH_STRAVA_CLIENT_SECRET + */ + clientSecret?: string + + /** + * Strava OAuth Scope + * @default [] + * @see https://developers.strava.com/docs/authentication/ # Details About Requesting Access + * @example ['read', 'read_all', 'profile:read_all', 'profile:write', 'activity:read', 'activity:read_all', 'activity:write'] + */ + scope?: string[] + + /** + * Redirect URL to allow overriding for situations like prod failing to determine public hostname + * @default process.env.NUXT_OAUTH_STRAVA_REDIRECT_URL or current URL + */ + redirectURL?: string + + /** + * To show the authorization prompt to the user, 'force' will always show the prompt + * @default 'auto' + * @see https://developers.strava.com/docs/authentication/ # Details About Requesting Access + */ + approvalPrompt?: 'auto' | 'force' +} + +export interface OAuthStravaUser { + /** + * The unique identifier of the athlete + */ + id: number + + /** + * The username of the athlete + */ + username: string + + /** + * Resource state, indicates level of detail. + * - Meta (1): Basic information + * - Summary (2): Summary information + * - Detail (3): Detailed information + * @see https://developers.strava.com/docs/reference/#api-models-DetailedAthlete + */ + resource_state: 1 | 2 | 3 + + /** + * The athlete's first name + */ + firstname: string + + /** + * The athlete's last name + */ + lastname: string + + /** + * The athlete's bio + */ + bio: string + + /** + * The athlete's city + */ + city: string + + /** + * The athlete's state or geographical region + */ + state: string + + /** + * The athlete's country + */ + country: string + + /** + * The athlete's sex + */ + sex: string + + /** + * Whether the athlete has any Summit subscription + * @see https://developers.strava.com/docs/reference/#api-models-DetailedAthlete + */ + summit: boolean + + /** + * The time at which the athlete was created + */ + created_at: Date + + /** + * The time at which the athlete was last updated + */ + updated_at: Date + + /** + * The athlete's weight + */ + weight: number + + /** + * URL to a 124x124 pixel profile picture + */ + profile_medium: string + + /** + * URL to a 62x62 pixel profile picture + */ + profile: string + + /** + * The athlete's timezone + */ + timezone: string +} + +export interface OAuthStravaTokens { + token_type: 'Bearer' + expires_at: number + expires_in: number + access_token: string + refresh_token: string + athlete: OAuthStravaUser + error?: string +} + +export function defineOAuthStravaEventHandler({ + config, + onSuccess, + onError, +}: OAuthConfig) { + return eventHandler(async (event: H3Event) => { + config = defu(config, useRuntimeConfig(event).oauth?.strava) as OAuthStravaConfig + + const query = getQuery<{ code?: string, state?: string, error?: string }>(event) + + if (query.error) { + const error = createError({ + statusCode: 401, + message: `Strava login failed: ${query.error || 'Unknown error'}`, + data: query, + }) + if (!onError) throw error + return onError(event, error) + } + + if (!config.clientId || !config.clientSecret) { + return handleMissingConfiguration( + event, + 'strava', + ['clientId', 'clientSecret'], + onError, + ) + } + + const authorizationURL = 'https://www.strava.com/oauth/authorize' + const tokenURL = 'https://www.strava.com/oauth/token' + const redirectURL = config.redirectURL || getOAuthRedirectURL(event) + + if (!query.code) { + // Redirect to Strava login page + return sendRedirect( + event, + withQuery(authorizationURL, { + client_id: config.clientId, + redirect_uri: redirectURL, + response_type: 'code', + approval_prompt: config.approvalPrompt || 'auto', + scope: config.scope, + }), + ) + } + + const tokens: OAuthStravaTokens = await requestAccessToken(tokenURL, { + body: { + client_id: config.clientId, + client_secret: config.clientSecret, + code: query.code as string, + grant_type: 'authorization_code', + redirect_uri: redirectURL, + }, + }) + + if (tokens.error) { + return handleAccessTokenErrorResponse(event, 'strava', tokens, onError) + } + + const user: OAuthStravaUser = await $fetch('https://www.strava.com/api/v3/athlete', { + headers: { + Authorization: `Bearer ${tokens.access_token}`, + }, + }) + + return onSuccess(event, { + user, + tokens, + }) + }) +} diff --git a/src/runtime/types/oauth-config.ts b/src/runtime/types/oauth-config.ts index 59d4c41..eaa803e 100644 --- a/src/runtime/types/oauth-config.ts +++ b/src/runtime/types/oauth-config.ts @@ -1,6 +1,6 @@ import type { H3Event, H3Error } from 'h3' -export type OAuthProvider = 'auth0' | 'authentik' | 'battledotnet' | 'cognito' | 'discord' | 'dropbox' | 'facebook' | 'github' | 'gitlab' | 'google' | 'instagram' | 'keycloak' | 'linear' | 'linkedin' | 'microsoft' | 'paypal' | 'polar' | 'spotify' | 'seznam' | 'steam' | 'tiktok' | 'twitch' | 'vk' | 'workos' | 'x' | 'xsuaa' | 'yandex' | 'zitadel' | (string & {}) +export type OAuthProvider = 'auth0' | 'authentik' | 'battledotnet' | 'cognito' | 'discord' | 'dropbox' | 'facebook' | 'github' | 'gitlab' | 'google' | 'instagram' | 'keycloak' | 'linear' | 'linkedin' | 'microsoft' | 'paypal' | 'polar' | 'spotify' | 'seznam' | 'steam' | 'strava' | 'tiktok' | 'twitch' | 'vk' | 'workos' | 'x' | 'xsuaa' | 'yandex' | 'zitadel' | (string & {}) export type OnError = (event: H3Event, error: H3Error) => Promise | void From 69f37b235ca61c31d5a3e2cf36221463ff20b246 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Chopin?= Date: Mon, 9 Dec 2024 10:57:34 +0100 Subject: [PATCH 2/2] chore: add missing icons dep --- playground/package.json | 1 + pnpm-lock.yaml | 10 ++++++++++ 2 files changed, 11 insertions(+) diff --git a/playground/package.json b/playground/package.json index 35a348a..3024190 100644 --- a/playground/package.json +++ b/playground/package.json @@ -10,6 +10,7 @@ "dependencies": { "@iconify-json/gravity-ui": "^1.2.2", "@iconify-json/iconoir": "^1.2.3", + "@iconify-json/logos": "^1.2.3", "@tsndr/cloudflare-worker-jwt": "^3.1.3", "nuxt": "^3.14.159", "nuxt-auth-utils": "latest", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6464ebc..0c31ca1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -99,6 +99,9 @@ importers: '@iconify-json/iconoir': specifier: ^1.2.3 version: 1.2.3 + '@iconify-json/logos': + specifier: ^1.2.3 + version: 1.2.3 '@tsndr/cloudflare-worker-jwt': specifier: ^3.1.3 version: 3.1.3 @@ -1029,6 +1032,9 @@ packages: '@iconify-json/iconoir@1.2.3': resolution: {integrity: sha512-CIPFznjl417Y5zBPqZP4+lhHwdTr7aDenzUOL/z92ufuMoX7SOZbZ8dK9/wNaLwCr+2w+MrxfVKc7zjUj9K0Dg==} + '@iconify-json/logos@1.2.3': + resolution: {integrity: sha512-JLHS5hgZP1b55EONAWNeqBUuriRfRNKWXK4cqYx0PpVaJfIIMiiMxFfvoQiX/bkE9XgkLhcKmDUqL3LXPdXPwQ==} + '@iconify-json/simple-icons@1.2.11': resolution: {integrity: sha512-AHCGDtBRqP+JzAbBzgO8uN/08CXxEmuaC6lQQZ3b5burKhRU12AJnJczwbUw2K5Mb/U85EpSUNhYMG3F28b8NA==} @@ -6049,6 +6055,10 @@ snapshots: dependencies: '@iconify/types': 2.0.0 + '@iconify-json/logos@1.2.3': + dependencies: + '@iconify/types': 2.0.0 + '@iconify-json/simple-icons@1.2.11': dependencies: '@iconify/types': 2.0.0