Skip to content

Commit a612418

Browse files
committed
github providers
1 parent e3567ea commit a612418

File tree

5 files changed

+243
-3
lines changed

5 files changed

+243
-3
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
- [2025-02-13] [github providers](https://github.com/RubricLab/auth/commit/e98d787af8ac54d174fddd8c5938b960d5d72462)
12
- [2025-02-07] [expire sessions after 30 days](https://github.com/RubricLab/auth/commit/bcb4a8bf8a3601afffb2ad1ecef21061850e24db)
23
- [2025-02-07] [prisma adapter, type exports](https://github.com/RubricLab/auth/commit/ea24d4a2a5f7ec464eba3da5c2be96c9e2bf09af)
34
- [2025-02-06] [expire stale auth requests](https://github.com/RubricLab/auth/commit/117d98f5f01aa195f3a29edd8c238fbc61782a90)

lib/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,11 @@ export {
1313
createGoogleAuthorizationProvider
1414
} from './providers/google'
1515

16+
export {
17+
createGithubAuthenticationProvider,
18+
createGithubAuthorizationProvider
19+
} from './providers/github'
20+
1621
export { createResendMagicLinkAuthenticationProvider } from './providers/resend'
1722

1823
export { prismaAdapter } from './providers/prisma'

lib/providers/github.ts

Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
import { createOauth2AuthenticationProvider, createOauth2AuthorizationProvider } from '../utils'
2+
3+
// GitHub OAuth Scopes
4+
// Reference: https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/scopes-for-oauth-apps
5+
export type GitHubScope =
6+
// User scopes
7+
| 'read:user'
8+
| 'user'
9+
| 'user:email'
10+
| 'user:follow'
11+
// Repository scopes
12+
| 'repo'
13+
| 'repo:status'
14+
| 'repo:deployment'
15+
| 'public_repo'
16+
| 'repo:invite'
17+
// Notifications and discussions
18+
| 'notifications'
19+
// Gists and snippets
20+
| 'gist'
21+
// Organization scopes
22+
| 'read:org'
23+
| 'admin:org'
24+
| 'write:org'
25+
// Projects
26+
| 'project'
27+
// Packages
28+
| 'read:packages'
29+
| 'write:packages'
30+
| 'delete:packages'
31+
// Workflows and Actions
32+
| 'workflow'
33+
34+
interface GitHubEmail {
35+
email: string
36+
primary: boolean
37+
verified: boolean
38+
visibility: string | null
39+
}
40+
41+
export const createGithubAuthenticationProvider = ({
42+
githubClientId,
43+
githubClientSecret
44+
}: {
45+
githubClientId: string
46+
githubClientSecret: string
47+
}) =>
48+
createOauth2AuthenticationProvider({
49+
getAuthenticationUrl: async ({ redirectUri, state }) => {
50+
const url = new URL('https://github.com/login/oauth/authorize')
51+
52+
url.searchParams.set('client_id', githubClientId)
53+
url.searchParams.set('redirect_uri', redirectUri)
54+
url.searchParams.set('state', state)
55+
url.searchParams.set('access_type', 'offline')
56+
url.searchParams.set('scope', (['read:user', 'user:email'] satisfies GitHubScope[]).join(' '))
57+
58+
return url
59+
},
60+
getToken: async ({ code, redirectUri }) => {
61+
const response = await fetch('https://github.com/login/oauth/access_token', {
62+
method: 'POST',
63+
headers: {
64+
'Content-Type': 'application/json',
65+
Accept: 'application/json'
66+
},
67+
body: JSON.stringify({
68+
client_id: githubClientId,
69+
client_secret: githubClientSecret,
70+
code,
71+
redirect_uri: redirectUri
72+
})
73+
})
74+
75+
const data = await response.json()
76+
77+
if (!response.ok || data.error) {
78+
console.error('Token exchange failed:', data)
79+
throw new Error(`Failed to get token: ${data.error_description || data.error}`)
80+
}
81+
82+
return {
83+
accessToken: data.access_token,
84+
refreshToken: '', // GitHub doesn't provide refresh tokens
85+
expiresAt: new Date(Date.now() + 1000 * 60 * 365) // GitHub tokens don't expire by default
86+
}
87+
},
88+
getUser: async ({ accessToken }) => {
89+
const response = await fetch('https://api.github.com/user', {
90+
headers: {
91+
Authorization: `Bearer ${accessToken}`,
92+
Accept: 'application/vnd.github.v3+json'
93+
}
94+
})
95+
96+
const data = await response.json()
97+
98+
if (!response.ok) {
99+
console.error('Failed to get user:', data)
100+
throw new Error(`Failed to get user: ${data.message}`)
101+
}
102+
103+
const emailResponse = await fetch('https://api.github.com/user/emails', {
104+
headers: {
105+
Authorization: `Bearer ${accessToken}`,
106+
Accept: 'application/vnd.github.v3+json'
107+
}
108+
})
109+
110+
const emails = (await emailResponse.json()) as GitHubEmail[]
111+
const primaryEmail = emails.find(email => email.primary)?.email || data.email
112+
113+
return {
114+
accountId: data.id.toString(),
115+
email: primaryEmail
116+
}
117+
},
118+
refreshToken: async ({ refreshToken }) => {
119+
// GitHub's OAuth tokens don't expire by default
120+
throw new Error('GitHub OAuth tokens do not support refreshing')
121+
}
122+
})
123+
124+
export const createGithubAuthorizationProvider = ({
125+
githubClientId,
126+
githubClientSecret,
127+
scopes
128+
}: {
129+
githubClientId: string
130+
githubClientSecret: string
131+
scopes: GitHubScope[]
132+
}) =>
133+
createOauth2AuthorizationProvider({
134+
getAuthorizationUrl: async ({ redirectUri, state }) => {
135+
const url = new URL('https://github.com/login/oauth/authorize')
136+
137+
url.searchParams.set('client_id', githubClientId)
138+
url.searchParams.set('redirect_uri', redirectUri)
139+
url.searchParams.set('state', state)
140+
url.searchParams.set('scope', scopes.join(' '))
141+
142+
return url
143+
},
144+
getToken: async ({ code, redirectUri }) => {
145+
const response = await fetch('https://github.com/login/oauth/access_token', {
146+
method: 'POST',
147+
headers: {
148+
'Content-Type': 'application/json',
149+
Accept: 'application/json'
150+
},
151+
body: JSON.stringify({
152+
client_id: githubClientId,
153+
client_secret: githubClientSecret,
154+
code,
155+
redirect_uri: redirectUri
156+
})
157+
})
158+
159+
const data = await response.json()
160+
161+
if (!response.ok || data.error) {
162+
console.error('Token exchange failed:', data)
163+
throw new Error(`Failed to get token: ${data.error_description || data.error}`)
164+
}
165+
166+
return {
167+
accessToken: data.access_token,
168+
refreshToken: '', // GitHub doesn't provide refresh tokens
169+
expiresAt: new Date(Date.now() + 1000 * 60 * 365) // GitHub tokens don't expire by default
170+
}
171+
},
172+
getUser: async ({ accessToken }) => {
173+
const response = await fetch('https://api.github.com/user', {
174+
headers: {
175+
Authorization: `Bearer ${accessToken}`,
176+
Accept: 'application/vnd.github.v3+json'
177+
}
178+
})
179+
180+
const data = await response.json()
181+
182+
if (!response.ok) {
183+
console.error('Failed to get user:', data)
184+
throw new Error(`Failed to get user: ${data.message}`)
185+
}
186+
187+
// Get user's email if it's not public
188+
const emailResponse = await fetch('https://api.github.com/user/emails', {
189+
headers: {
190+
Authorization: `Bearer ${accessToken}`,
191+
Accept: 'application/vnd.github.v3+json'
192+
}
193+
})
194+
195+
const emails = (await emailResponse.json()) as GitHubEmail[]
196+
const primaryEmail = emails.find(email => email.primary)?.email || data.email
197+
198+
return {
199+
accountId: data.id.toString(),
200+
email: primaryEmail
201+
}
202+
},
203+
refreshToken: async ({ refreshToken }) => {
204+
// GitHub's OAuth tokens don't expire by default
205+
throw new Error('GitHub OAuth tokens do not support refreshing')
206+
}
207+
})

lib/providers/google.ts

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,27 @@
11
import { createOauth2AuthenticationProvider, createOauth2AuthorizationProvider } from '../utils'
22

3+
// Google OAuth Scopes
4+
// Reference: https://developers.google.com/identity/protocols/oauth2/scopes
5+
export type GoogleScope =
6+
// User info scopes
7+
| 'https://www.googleapis.com/auth/userinfo.profile'
8+
| 'https://www.googleapis.com/auth/userinfo.email'
9+
// Gmail scopes
10+
| 'https://www.googleapis.com/auth/gmail.readonly'
11+
| 'https://www.googleapis.com/auth/gmail.modify'
12+
| 'https://www.googleapis.com/auth/gmail.compose'
13+
| 'https://www.googleapis.com/auth/gmail.send'
14+
| 'https://www.googleapis.com/auth/gmail.full'
15+
// Drive scopes
16+
| 'https://www.googleapis.com/auth/drive.readonly'
17+
| 'https://www.googleapis.com/auth/drive.file'
18+
| 'https://www.googleapis.com/auth/drive'
19+
| 'https://www.googleapis.com/auth/drive.appdata'
20+
// Calendar scopes
21+
| 'https://www.googleapis.com/auth/calendar.readonly'
22+
| 'https://www.googleapis.com/auth/calendar.events'
23+
| 'https://www.googleapis.com/auth/calendar'
24+
325
export const createGoogleAuthenticationProvider = ({
426
googleClientId,
527
googleClientSecret
@@ -16,7 +38,12 @@ export const createGoogleAuthenticationProvider = ({
1638
url.searchParams.set('response_type', 'code')
1739
url.searchParams.set(
1840
'scope',
19-
'https://www.googleapis.com/auth/userinfo.email https://www.googleapis.com/auth/userinfo.profile'
41+
(
42+
[
43+
'https://www.googleapis.com/auth/userinfo.email',
44+
'https://www.googleapis.com/auth/userinfo.profile'
45+
] satisfies GoogleScope[]
46+
).join(' ')
2047
)
2148
url.searchParams.set('state', state)
2249
url.searchParams.set('access_type', 'offline')
@@ -100,7 +127,7 @@ export const createGoogleAuthorizationProvider = ({
100127
}: {
101128
googleClientId: string
102129
googleClientSecret: string
103-
scopes: string[]
130+
scopes: GoogleScope[]
104131
}) =>
105132
createOauth2AuthorizationProvider({
106133
getAuthorizationUrl: async ({ redirectUri, state }) => {

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
"lint:fix": "bun x biome check --fix --unsafe . && bun x biome lint --write --unsafe ."
1111
},
1212
"name": "@rubriclab/auth",
13-
"version": "0.0.26",
13+
"version": "0.0.27",
1414
"main": "lib/index.ts",
1515
"dependencies": {
1616
"@rubriclab/config": "*",

0 commit comments

Comments
 (0)