Skip to content

Commit

Permalink
feat: add strava oauth provider
Browse files Browse the repository at this point in the history
* feat: add strava oauth provider

* chore: add missing icons dep

---------

Co-authored-by: Sébastien Chopin <[email protected]>
  • Loading branch information
justpeterpan and atinux authored Dec 9, 2024
1 parent 25ece86 commit 96363b2
Show file tree
Hide file tree
Showing 10 changed files with 269 additions and 2 deletions.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,7 @@ It can also be set using environment variables:
- Seznam
- Spotify
- Steam
- Strava
- TikTok
- Twitch
- VK
Expand Down
3 changes: 3 additions & 0 deletions playground/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=
6 changes: 6 additions & 0 deletions playground/app.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions playground/auth.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ declare module '#auth-utils' {
zitadel?: string
authentik?: string
seznam?: string
strava?: string
}

interface UserSession {
Expand Down
1 change: 1 addition & 0 deletions playground/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
16 changes: 16 additions & 0 deletions playground/server/routes/auth/strava.get.ts
Original file line number Diff line number Diff line change
@@ -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, '/')
},
})
10 changes: 10 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 7 additions & 1 deletion src/module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,7 @@ export default defineNuxtModule<ModuleOptions>({
clientSecret: '',
redirectURL: '',
})
// GitHub OAuth
// GitLab OAuth
runtimeConfig.oauth.gitlab = defu(runtimeConfig.oauth.gitlab, {
clientId: '',
clientSecret: '',
Expand Down Expand Up @@ -341,5 +341,11 @@ export default defineNuxtModule<ModuleOptions>({
clientSecret: '',
redirectURL: '',
})
// Strava OAuth
runtimeConfig.oauth.strava = defu(runtimeConfig.oauth.strava, {
clientId: '',
clientSecret: '',
redirectURL: '',
})
},
})
223 changes: 223 additions & 0 deletions src/runtime/server/lib/oauth/strava.ts
Original file line number Diff line number Diff line change
@@ -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<OAuthStravaConfig, OAuthStravaUser>) {
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,
})
})
}
2 changes: 1 addition & 1 deletion src/runtime/types/oauth-config.ts
Original file line number Diff line number Diff line change
@@ -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> | void

Expand Down

0 comments on commit 96363b2

Please sign in to comment.