| 1 | import { type Database, schema } from '@ghost/db'; |
| 2 | import { Inject, Injectable |
| 3 | import { AuthService } |
| 4 | import { isAPIError } |
| 5 | import { eq } |
| 6 | |
| 7 | import { DATABASE } |
| 8 | import type { Auth |
| 9 | import type { Executor |
| 10 | import { callbackMatches } |
| 11 | import { findOauthApp, type |
| 12 | import { |
| 13 | type OAuthReply, |
| 14 | oauthError, |
| 15 | } from '../../lib/github/oauth-response.js'; |
| 16 | import { |
| 17 | ACCESS_TOKEN_EXPIRES_IN, |
| 18 | issueRefreshToken, |
| 19 | REFRESH_TOKEN_EXPIRES_IN, |
| 20 | spendRefreshToken, |
| 21 | } from '../../lib/github/refresh-tokens.js'; |
| 22 | import { grantableScopes, NO_SCOPE |
| 23 | |
| 24 | const DEVICE_GRANT = ' |
| 25 | |
| 26 | export type AuthorizeQuery |
| 27 | client_id?: string |
| 28 | redirect_uri?: string |
| 29 | scope?: string |
| 30 | state?: string |
| 31 | code_challenge?: string |
| 32 | code_challenge_method?: string |
| 33 | prompt?: string |
| 34 | }; |
| 35 | |
| 36 | export type AccessTokenRequest |
| 37 | client_id?: string |
| 38 | client_secret?: string |
| 39 | device_code?: string |
| 40 | grant_type?: string |
| 41 | code?: string |
| 42 | redirect_uri?: string |
| 43 | code_verifier?: string |
| 44 | refresh_token?: string |
| 45 | }; |
| 46 | |
| 47 | /** GitHub's OAuth flows: the device flow over Better Auth's deviceAuthorization plugin, the web flow over the oauth-provider plugin. Every grant ends in a scoped API key, never the plugins' own tokens. */ |
| 48 | @Injectable() |
| 49 | export class OauthService |
| 50 | constructor( |
| 51 | private readonly auth |
| 52 | @Inject(DATABASE) |
| 53 | ) {} |
| 54 | |
| 55 | /** Where to send the browser, or why not: the app and its callback are checked before anything redirects, so a bad request never leaves the API host. */ |
| 56 | async authorize( |
| 57 | query: AuthorizeQuery |
| 58 | ): Promise<{ |
| 59 | const app = |
| 60 | ? await findOauthApp( |
| 61 | : null; |
| 62 | if (!app) |
| 63 | const redirectUri = |
| 64 | if ( |
| 65 | !redirectUri || |
| 66 | !app.redirectUris.some |
| 67 | callbackMatches(callback, redirectUri |
| 68 | ) |
| 69 | ) |
| 70 | return { |
| 71 | refused: 'The redirect_uri is not associated with this application.', |
| 72 | }; |
| 73 | // GitHub reads commas as separators too. |
| 74 | const scopes = |
| 75 | const params = |
| 76 | response_type: 'code', |
| 77 | client_id: app.clientId |
| 78 | redirect_uri: redirectUri, |
| 79 | scope: scopes.length |
| 80 | ...(query.state |
| 81 | ...(query.code_challenge |
| 82 | ...(query.code_challenge_method |
| 83 | code_challenge_method: query.code_challenge_method |
| 84 | }), |
| 85 | // `consent` asks again even after an earlier approval; the plugin reads the rest of OIDC's values too and ignores unknown ones. |
| 86 | ...(query.prompt |
| 87 | }); |
| 88 | return { redirect: |
| 89 | } |
| 90 | |
| 91 | async deviceCode(body |
| 92 | client_id?: string |
| 93 | scope?: string |
| 94 | }): Promise |
| 95 | const app = |
| 96 | if (!app || |
| 97 | return oauthError(400, |
| 98 | const code = |
| 99 | body: { |
| 100 | client_id: app.clientId |
| 101 | scope: grantableScopes(body |
| 102 | }, |
| 103 | }); |
| 104 | return { |
| 105 | status: 200, |
| 106 | body: { |
| 107 | device_code: code.device_code |
| 108 | user_code: code.user_code |
| 109 | verification_uri: code.verification_uri |
| 110 | expires_in: code.expires_in |
| 111 | interval: code.interval |
| 112 | }, |
| 113 | }; |
| 114 | } |
| 115 | |
| 116 | /** Dispatches as GitHub does: the device and refresh grants when `grant_type` names them, the web flow's code exchange when a `code` is sent, which GitHub's clients send without a `grant_type`. */ |
| 117 | async accessToken(body |
| 118 | const app = |
| 119 | ? await findOauthApp( |
| 120 | : null; |
| 121 | if (body.grant_type |
| 122 | if (!app) |
| 123 | return this.deviceGrant |
| 124 | } |
| 125 | if ( |
| 126 | body.grant_type === |
| 127 | typeof body.refresh_token |
| 128 | ) { |
| 129 | if (!app) |
| 130 | return this.refreshGrant |
| 131 | client_secret: body.client_secret |
| 132 | refresh_token: body.refresh_token |
| 133 | }); |
| 134 | } |
| 135 | if (body.code |
| 136 | if (!app) |
| 137 | return this.codeGrant |
| 138 | } |
| 139 | return oauthError(400, |
| 140 | } |
| 141 | |
| 142 | private async deviceGrant |
| 143 | app: OauthApp |
| 144 | deviceCode: string, |
| 145 | ): Promise<OAuthReply |
| 146 | let granted: |
| 147 | try { |
| 148 | granted = await |
| 149 | body: { |
| 150 | grant_type: DEVICE_GRANT, |
| 151 | device_code: deviceCode, |
| 152 | client_id: app.clientId |
| 153 | }, |
| 154 | }); |
| 155 | } catch (error) |
| 156 | if (!isAPIError(error |
| 157 | // GitHub answers pending, slow_down, expired and denied with 200 and `error` in the body; gh reads the body, not the status. |
| 158 | const { error: |
| 159 | error?: string |
| 160 | error_description?: string |
| 161 | }; |
| 162 | return { |
| 163 | status: 200, |
| 164 | body: { |
| 165 | error: code ?? |
| 166 | ...(error_description && |
| 167 | }, |
| 168 | }; |
| 169 | } |
| 170 | |
| 171 | // The plugin answers with a browser session; gh gets a scoped API key instead, and the session is ended. |
| 172 | const [session] |
| 173 | .delete(schema.session |
| 174 | .where(eq(schema |
| 175 | .returning({ userId: |
| 176 | if (!session) |
| 177 | return { |
| 178 | status: 200, |
| 179 | body: await this |
| 180 | app, |
| 181 | session.userId, |
| 182 | grantableScopes(granted.scope |
| 183 | ), |
| 184 | }; |
| 185 | } |
| 186 | |
| 187 | private async codeGrant |
| 188 | app: OauthApp |
| 189 | body: { |
| 190 | client_secret?: string |
| 191 | code: string |
| 192 | redirect_uri?: string |
| 193 | code_verifier?: string |
| 194 | }, |
| 195 | ): Promise<OAuthReply |
| 196 | const redirectUri = |
| 197 | if ( |
| 198 | !redirectUri || |
| 199 | !app.redirectUris.some |
| 200 | callbackMatches(callback, redirectUri |
| 201 | ) |
| 202 | ) |
| 203 | return oauthError(200, |
| 204 | const client = |
| 205 | client_id: app.clientId |
| 206 | client_secret: body.client_secret |
| 207 | }; |
| 208 | let granted: |
| 209 | try { |
| 210 | granted = await |
| 211 | body: { |
| 212 | ...client, |
| 213 | grant_type: 'authorization_code', |
| 214 | code: body.code |
| 215 | redirect_uri: redirectUri, |
| 216 | code_verifier: body.code_verifier |
| 217 | }, |
| 218 | }); |
| 219 | } catch (error) |
| 220 | if (!isAPIError(error |
| 221 | const { error: |
| 222 | return oauthError( |
| 223 | 200, |
| 224 | code === 'invalid_client' |
| 225 | ? 'incorrect_client_credentials' |
| 226 | : 'bad_verification_code', |
| 227 | ); |
| 228 | } |
| 229 | |
| 230 | // The plugin's token is never handed out: Ghost reads whose it is, revokes it, and mints a key in its place. |
| 231 | const token = |
| 232 | ...client, |
| 233 | token: granted.access_token |
| 234 | token_type_hint: 'access_token' as |
| 235 | }; |
| 236 | const { sub |
| 237 | await this.auth |
| 238 | return { |
| 239 | status: 200, |
| 240 | body: await this |
| 241 | }; |
| 242 | } |
| 243 | |
| 244 | /** The new pair is minted under the app's current switch, so turning it off moves the app to keys that never expire on its next refresh. The old key goes only once the new pair exists. */ |
| 245 | private async refreshGrant |
| 246 | app: OauthApp |
| 247 | body: { |
| 248 | ): Promise<OAuthReply |
| 249 | if ( |
| 250 | typeof body.client_secret |
| 251 | !(await this. |
| 252 | ) |
| 253 | return oauthError(200, |
| 254 | const refreshToken = |
| 255 | const token = |
| 256 | const grant = |
| 257 | if (!grant) |
| 258 | const minted = |
| 259 | await tx |
| 260 | .delete(schema.apikey |
| 261 | .where(eq(schema |
| 262 | return minted; |
| 263 | }); |
| 264 | if (!token) |
| 265 | return { status: |
| 266 | } |
| 267 | |
| 268 | /** The plugin checks a secret its own way; introspecting a token it does not hold fails only on bad credentials. */ |
| 269 | private async authenticates |
| 270 | try { |
| 271 | await this.auth |
| 272 | body: { |
| 273 | client_id: app.clientId |
| 274 | client_secret: clientSecret, |
| 275 | token: 'ghost-client-check', |
| 276 | token_type_hint: 'access_token', |
| 277 | }, |
| 278 | }); |
| 279 | return true; |
| 280 | } catch (error) |
| 281 | if ( |
| 282 | isAPIError(error) && |
| 283 | (error.body as |
| 284 | ) |
| 285 | return false; |
| 286 | throw error; |
| 287 | } |
| 288 | } |
| 289 | |
| 290 | /** One token response for every grant, so the device flow, the code exchange and a refresh answer alike. */ |
| 291 | private async mint |
| 292 | app: OauthApp |
| 293 | userId: string, |
| 294 | scopes: string[] |
| 295 | executor: Executor |
| 296 | ) { |
| 297 | const { key, |
| 298 | body: { |
| 299 | userId, |
| 300 | name: app.name |
| 301 | permissions: { scopes |
| 302 | metadata: { oauthClientId: app |
| 303 | ...(app.expireUserTokens |
| 304 | }, |
| 305 | }); |
| 306 | const token = |
| 307 | access_token: key, |
| 308 | token_type: 'bearer', |
| 309 | scope: scopes.join |
| 310 | }; |
| 311 | if (!app.expireUserTokens |
| 312 | return { |
| 313 | ...token, |
| 314 | expires_in: ACCESS_TOKEN_EXPIRES_IN, |
| 315 | refresh_token: await issueRefreshToken |
| 316 | userId, |
| 317 | scopes, |
| 318 | accessKeyId: id, |
| 319 | }), |
| 320 | refresh_token_expires_in: REFRESH_TOKEN_EXPIRES_IN, |
| 321 | }; |
| 322 | } |
| 323 | } |