Skip to content

Commit 96363b2

Browse files
justpeterpanatinux
andauthored
feat: add strava oauth provider
* feat: add strava oauth provider * chore: add missing icons dep --------- Co-authored-by: Sébastien Chopin <[email protected]>
1 parent 25ece86 commit 96363b2

File tree

10 files changed

+269
-2
lines changed

10 files changed

+269
-2
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,7 @@ It can also be set using environment variables:
224224
- Seznam
225225
- Spotify
226226
- Steam
227+
- Strava
227228
- TikTok
228229
- Twitch
229230
- VK

playground/.env.example

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,3 +93,6 @@ NUXT_OAUTH_ZITADEL_DOMAIN=
9393
NUXT_OAUTH_AUTHENTIK_CLIENT_ID=
9494
NUXT_OAUTH_AUTHENTIK_CLIENT_SECRET=
9595
NUXT_OAUTH_AUTHENTIK_DOMAIN=
96+
# Strava
97+
NUXT_OAUTH_STRAVA_CLIENT_ID=
98+
NUXT_OAUTH_STRAVA_CLIENT_SECRET=

playground/app.vue

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,12 @@ const providers = computed(() =>
171171
disabled: Boolean(user.value?.seznam),
172172
icon: 'i-gravity-ui-lock',
173173
},
174+
{
175+
label: user.value?.strava || 'Strava',
176+
to: '/auth/strava',
177+
disabled: Boolean(user.value?.strava),
178+
icon: 'i-simple-icons-strava',
179+
},
174180
].map(p => ({
175181
...p,
176182
prefetch: false,

playground/auth.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ declare module '#auth-utils' {
3131
zitadel?: string
3232
authentik?: string
3333
seznam?: string
34+
strava?: string
3435
}
3536

3637
interface UserSession {

playground/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
"dependencies": {
1111
"@iconify-json/gravity-ui": "^1.2.2",
1212
"@iconify-json/iconoir": "^1.2.3",
13+
"@iconify-json/logos": "^1.2.3",
1314
"@tsndr/cloudflare-worker-jwt": "^3.1.3",
1415
"nuxt": "^3.14.159",
1516
"nuxt-auth-utils": "latest",
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
export default defineOAuthStravaEventHandler({
2+
config: {
3+
approvalPrompt: 'force',
4+
scope: ['profile:read_all'],
5+
},
6+
async onSuccess(event, { user }) {
7+
await setUserSession(event, {
8+
user: {
9+
strava: `${user.firstname} ${user.lastname}`,
10+
},
11+
loggedInAt: Date.now(),
12+
})
13+
14+
return sendRedirect(event, '/')
15+
},
16+
})

pnpm-lock.yaml

Lines changed: 10 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/module.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -164,7 +164,7 @@ export default defineNuxtModule<ModuleOptions>({
164164
clientSecret: '',
165165
redirectURL: '',
166166
})
167-
// GitHub OAuth
167+
// GitLab OAuth
168168
runtimeConfig.oauth.gitlab = defu(runtimeConfig.oauth.gitlab, {
169169
clientId: '',
170170
clientSecret: '',
@@ -341,5 +341,11 @@ export default defineNuxtModule<ModuleOptions>({
341341
clientSecret: '',
342342
redirectURL: '',
343343
})
344+
// Strava OAuth
345+
runtimeConfig.oauth.strava = defu(runtimeConfig.oauth.strava, {
346+
clientId: '',
347+
clientSecret: '',
348+
redirectURL: '',
349+
})
344350
},
345351
})
Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
1+
import type { H3Event } from 'h3'
2+
import { eventHandler, getQuery, sendRedirect } from 'h3'
3+
import { withQuery } from 'ufo'
4+
import { defu } from 'defu'
5+
import {
6+
getOAuthRedirectURL,
7+
handleAccessTokenErrorResponse,
8+
handleMissingConfiguration,
9+
requestAccessToken,
10+
} from '../utils'
11+
import { useRuntimeConfig, createError } from '#imports'
12+
import type { OAuthConfig } from '#auth-utils'
13+
14+
export interface OAuthStravaConfig {
15+
/**
16+
* Strava OAuth Client ID
17+
* @default process.env.NUXT_OAUTH_STRAVA_CLIENT_ID
18+
*/
19+
clientId?: string
20+
21+
/**
22+
* Strava OAuth Client Secret
23+
* @default process.env.NUXT_OAUTH_STRAVA_CLIENT_SECRET
24+
*/
25+
clientSecret?: string
26+
27+
/**
28+
* Strava OAuth Scope
29+
* @default []
30+
* @see https://developers.strava.com/docs/authentication/ # Details About Requesting Access
31+
* @example ['read', 'read_all', 'profile:read_all', 'profile:write', 'activity:read', 'activity:read_all', 'activity:write']
32+
*/
33+
scope?: string[]
34+
35+
/**
36+
* Redirect URL to allow overriding for situations like prod failing to determine public hostname
37+
* @default process.env.NUXT_OAUTH_STRAVA_REDIRECT_URL or current URL
38+
*/
39+
redirectURL?: string
40+
41+
/**
42+
* To show the authorization prompt to the user, 'force' will always show the prompt
43+
* @default 'auto'
44+
* @see https://developers.strava.com/docs/authentication/ # Details About Requesting Access
45+
*/
46+
approvalPrompt?: 'auto' | 'force'
47+
}
48+
49+
export interface OAuthStravaUser {
50+
/**
51+
* The unique identifier of the athlete
52+
*/
53+
id: number
54+
55+
/**
56+
* The username of the athlete
57+
*/
58+
username: string
59+
60+
/**
61+
* Resource state, indicates level of detail.
62+
* - Meta (1): Basic information
63+
* - Summary (2): Summary information
64+
* - Detail (3): Detailed information
65+
* @see https://developers.strava.com/docs/reference/#api-models-DetailedAthlete
66+
*/
67+
resource_state: 1 | 2 | 3
68+
69+
/**
70+
* The athlete's first name
71+
*/
72+
firstname: string
73+
74+
/**
75+
* The athlete's last name
76+
*/
77+
lastname: string
78+
79+
/**
80+
* The athlete's bio
81+
*/
82+
bio: string
83+
84+
/**
85+
* The athlete's city
86+
*/
87+
city: string
88+
89+
/**
90+
* The athlete's state or geographical region
91+
*/
92+
state: string
93+
94+
/**
95+
* The athlete's country
96+
*/
97+
country: string
98+
99+
/**
100+
* The athlete's sex
101+
*/
102+
sex: string
103+
104+
/**
105+
* Whether the athlete has any Summit subscription
106+
* @see https://developers.strava.com/docs/reference/#api-models-DetailedAthlete
107+
*/
108+
summit: boolean
109+
110+
/**
111+
* The time at which the athlete was created
112+
*/
113+
created_at: Date
114+
115+
/**
116+
* The time at which the athlete was last updated
117+
*/
118+
updated_at: Date
119+
120+
/**
121+
* The athlete's weight
122+
*/
123+
weight: number
124+
125+
/**
126+
* URL to a 124x124 pixel profile picture
127+
*/
128+
profile_medium: string
129+
130+
/**
131+
* URL to a 62x62 pixel profile picture
132+
*/
133+
profile: string
134+
135+
/**
136+
* The athlete's timezone
137+
*/
138+
timezone: string
139+
}
140+
141+
export interface OAuthStravaTokens {
142+
token_type: 'Bearer'
143+
expires_at: number
144+
expires_in: number
145+
access_token: string
146+
refresh_token: string
147+
athlete: OAuthStravaUser
148+
error?: string
149+
}
150+
151+
export function defineOAuthStravaEventHandler({
152+
config,
153+
onSuccess,
154+
onError,
155+
}: OAuthConfig<OAuthStravaConfig, OAuthStravaUser>) {
156+
return eventHandler(async (event: H3Event) => {
157+
config = defu(config, useRuntimeConfig(event).oauth?.strava) as OAuthStravaConfig
158+
159+
const query = getQuery<{ code?: string, state?: string, error?: string }>(event)
160+
161+
if (query.error) {
162+
const error = createError({
163+
statusCode: 401,
164+
message: `Strava login failed: ${query.error || 'Unknown error'}`,
165+
data: query,
166+
})
167+
if (!onError) throw error
168+
return onError(event, error)
169+
}
170+
171+
if (!config.clientId || !config.clientSecret) {
172+
return handleMissingConfiguration(
173+
event,
174+
'strava',
175+
['clientId', 'clientSecret'],
176+
onError,
177+
)
178+
}
179+
180+
const authorizationURL = 'https://www.strava.com/oauth/authorize'
181+
const tokenURL = 'https://www.strava.com/oauth/token'
182+
const redirectURL = config.redirectURL || getOAuthRedirectURL(event)
183+
184+
if (!query.code) {
185+
// Redirect to Strava login page
186+
return sendRedirect(
187+
event,
188+
withQuery(authorizationURL, {
189+
client_id: config.clientId,
190+
redirect_uri: redirectURL,
191+
response_type: 'code',
192+
approval_prompt: config.approvalPrompt || 'auto',
193+
scope: config.scope,
194+
}),
195+
)
196+
}
197+
198+
const tokens: OAuthStravaTokens = await requestAccessToken(tokenURL, {
199+
body: {
200+
client_id: config.clientId,
201+
client_secret: config.clientSecret,
202+
code: query.code as string,
203+
grant_type: 'authorization_code',
204+
redirect_uri: redirectURL,
205+
},
206+
})
207+
208+
if (tokens.error) {
209+
return handleAccessTokenErrorResponse(event, 'strava', tokens, onError)
210+
}
211+
212+
const user: OAuthStravaUser = await $fetch('https://www.strava.com/api/v3/athlete', {
213+
headers: {
214+
Authorization: `Bearer ${tokens.access_token}`,
215+
},
216+
})
217+
218+
return onSuccess(event, {
219+
user,
220+
tokens,
221+
})
222+
})
223+
}

src/runtime/types/oauth-config.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { H3Event, H3Error } from 'h3'
22

3-
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 & {})
3+
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 & {})
44

55
export type OnError = (event: H3Event, error: H3Error) => Promise<void> | void
66

0 commit comments

Comments
 (0)