diff --git a/.changeset/cyan-balloons-lick.md b/.changeset/cyan-balloons-lick.md new file mode 100644 index 000000000..dc8b85009 --- /dev/null +++ b/.changeset/cyan-balloons-lick.md @@ -0,0 +1,5 @@ +--- +'@hono/oauth-providers': minor +--- + +Allow for access_type to be passed for "offline" mode, defaults to "online". Allowing for refresh tokens to be used in subsequent requests diff --git a/.changeset/green-files-thank.md b/.changeset/green-files-thank.md new file mode 100644 index 000000000..3646db72a --- /dev/null +++ b/.changeset/green-files-thank.md @@ -0,0 +1,5 @@ +--- +'@hono/oauth-providers': minor +--- + +Allow for an optional state arg to be passed to Google Auth middleware diff --git a/packages/oauth-providers/src/providers/google/authFlow.ts b/packages/oauth-providers/src/providers/google/authFlow.ts index a5fe6d01e..0f03886e9 100644 --- a/packages/oauth-providers/src/providers/google/authFlow.ts +++ b/packages/oauth-providers/src/providers/google/authFlow.ts @@ -1,8 +1,7 @@ import { HTTPException } from 'hono/http-exception' -import type { Token } from '../../types' import { toQueryParams } from '../../utils/objectToQuery' -import type { GoogleErrorResponse, GoogleTokenResponse, GoogleUser } from './types' +import type { GoogleErrorResponse, GoogleTokenResponse, GoogleUser, Token } from './types' type GoogleAuthFlow = { client_id: string @@ -14,6 +13,7 @@ type GoogleAuthFlow = { state?: string login_hint?: string prompt?: 'none' | 'consent' | 'select_account' + access_type?: 'online' | 'offline' } export class AuthFlow { @@ -26,6 +26,7 @@ export class AuthFlow { state: string | undefined login_hint: string | undefined prompt: 'none' | 'consent' | 'select_account' | undefined + access_type: 'online' | 'offline' | undefined user: Partial | undefined granted_scopes: string[] | undefined @@ -39,6 +40,7 @@ export class AuthFlow { state, code, token, + access_type }: GoogleAuthFlow) { this.client_id = client_id this.client_secret = client_secret @@ -49,6 +51,7 @@ export class AuthFlow { this.state = state this.code = code this.token = token + this.access_type = access_type this.user = undefined this.granted_scopes = undefined @@ -71,6 +74,7 @@ export class AuthFlow { include_granted_scopes: true, scope: this.scope.join(' '), state: this.state, + access_type: this.access_type }) return `https://accounts.google.com/o/oauth2/v2/auth?${parsedOptions}` } @@ -99,6 +103,7 @@ export class AuthFlow { this.token = { token: response.access_token, expires_in: response.expires_in, + refresh_token: response.refresh_token } this.granted_scopes = response.scope.split(' ') @@ -106,7 +111,13 @@ export class AuthFlow { } async getUserData() { - await this.getTokenFromCode() + + // Check if token is expired and refresh if necessary + if ( this.access_type === 'offline' && this.isTokenExpired() ) { + await this.refreshToken() + } else { + await this.getTokenFromCode() + } const response = (await fetch('https://www.googleapis.com/oauth2/v2/userinfo', { headers: { @@ -122,4 +133,41 @@ export class AuthFlow { this.user = response } } + + async refreshToken() { + if (!this.token?.refresh_token) { + throw new HTTPException(400, { message: 'Refresh token not found' }) + } + + const response = (await fetch('https://oauth2.googleapis.com/token', { + method: 'POST', + headers: { + 'content-type': 'application/json', + accept: 'application/json', + }, + body: JSON.stringify({ + client_id: this.client_id, + client_secret: this.client_secret, + refresh_token: this.token.refresh_token, + grant_type: 'refresh_token', + }), + }).then((res) => res.json())) as GoogleTokenResponse | GoogleErrorResponse + + if ('error' in response) { + throw new HTTPException(400, { message: response.error_description }) + } + + if ('access_token' in response) { + this.token.token = response.access_token + this.token.expires_in = response.expires_in + this.token.refresh_token = response.refresh_token + } + } + + isTokenExpired() { + const currentTime = Math.floor(Date.now() / 1000) + return currentTime >= (this.token?.expires_in || 0) + } + + } diff --git a/packages/oauth-providers/src/providers/google/googleAuth.ts b/packages/oauth-providers/src/providers/google/googleAuth.ts index bad111a8c..61b0cb3a9 100644 --- a/packages/oauth-providers/src/providers/google/googleAuth.ts +++ b/packages/oauth-providers/src/providers/google/googleAuth.ts @@ -13,6 +13,7 @@ export function googleAuth(options: { client_id?: string client_secret?: string state?: string + access_type?: 'online' | 'offline' }): MiddlewareHandler { return async (c, next) => { const newState = options.state || getRandomState() @@ -29,7 +30,9 @@ export function googleAuth(options: { token: { token: c.req.query('access_token') as string, expires_in: Number(c.req.query('expires-in')) as number, + refresh_token: c.req.query('refresh_token') as string, }, + access_type: options.access_type }) // Avoid CSRF attack by checking state diff --git a/packages/oauth-providers/src/providers/google/types.ts b/packages/oauth-providers/src/providers/google/types.ts index eafebf359..ae261ce21 100644 --- a/packages/oauth-providers/src/providers/google/types.ts +++ b/packages/oauth-providers/src/providers/google/types.ts @@ -13,6 +13,7 @@ export type GoogleTokenResponse = { scope: string token_type: string id_token: string + refresh_token: string } export type GoogleTokenInfoResponse = { @@ -36,3 +37,9 @@ export type GoogleUser = { picture: string locale: string } + +export type Token = { + token: string + expires_in: number + refresh_token: string +}