Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
174 changes: 174 additions & 0 deletions packages/core/auth-js/src/GoTrueClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,9 @@ import type {
MFAVerifyWebauthnParamFields,
MFAVerifyWebauthnParams,
OAuthResponse,
AuthOAuthServerApi,
AuthOAuthAuthorizationDetailsResponse,
AuthOAuthConsentResponse,
Prettify,
Provider,
ResendParams,
Expand Down Expand Up @@ -196,6 +199,12 @@ export default class GoTrueClient {
* Namespace for the MFA methods.
*/
mfa: GoTrueMFAApi
/**
* Namespace for the OAuth 2.1 authorization server methods.
* Only relevant when the OAuth 2.1 server is enabled in Supabase Auth.
* Used to implement the authorization code flow on the consent page.
*/
oauth: AuthOAuthServerApi
/**
* The storage key used to identify the values saved in localStorage
*/
Expand Down Expand Up @@ -322,6 +331,12 @@ export default class GoTrueClient {
webauthn: new WebAuthnApi(this),
}

this.oauth = {
getAuthorizationDetails: this._getAuthorizationDetails.bind(this),
approveAuthorization: this._approveAuthorization.bind(this),
denyAuthorization: this._denyAuthorization.bind(this),
}

if (this.persistSession) {
if (settings.storage) {
this.storage = settings.storage
Expand Down Expand Up @@ -3344,6 +3359,165 @@ export default class GoTrueClient {
})
}

/**
* Retrieves details about an OAuth authorization request.
* Only relevant when the OAuth 2.1 server is enabled in Supabase Auth.
*/
private async _getAuthorizationDetails(
authorizationId: string,
options?: { skipBrowserRedirect?: boolean }
): Promise<AuthOAuthAuthorizationDetailsResponse> {
try {
return await this._useSession(async (result) => {
const {
data: { session },
error: sessionError,
} = result

if (sessionError) {
return { data: null, error: sessionError }
}

if (!session) {
return { data: null, error: new AuthSessionMissingError() }
}

return await _request(
this.fetch,
'GET',
`${this.url}/oauth/authorizations/${authorizationId}`,
{
headers: this.headers,
jwt: session.access_token,
xform: (data: any) => {
// If the API returns redirect_uri, it means consent was already given
if (data.redirect_uri) {
// Automatically redirect in browser unless skipBrowserRedirect is true
if (isBrowser() && !options?.skipBrowserRedirect) {
window.location.assign(data.redirect_uri)
}
}
Comment on lines +3394 to +3399

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm a bit on the fence about this: it feels like the default should be that we return the redirect URL to the caller and let them handle the redirect given that these APIs are slightly lower level (as opposed to having some hosted consent page).

Copy link
Contributor Author

@cemalkilic cemalkilic Oct 23, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Completely get you, wanted to stick with the existing conventions for skipBrowserRedirect

// try to open on the browser
if (isBrowser() && !options.skipBrowserRedirect) {
window.location.assign(url)
}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess I would expect the SDK to redirect me when calling a method signInWithOAuth but as a user you might find it unexpected to be redirected when calling getAuthorizationDetails (personally I'd expect an object to be returned).


return { data, error: null }
},
}
)
})
} catch (error) {
if (isAuthError(error)) {
return { data: null, error }
}

throw error
}
}

/**
* Approves an OAuth authorization request.
* Only relevant when the OAuth 2.1 server is enabled in Supabase Auth.
*/
private async _approveAuthorization(
authorizationId: string,
options?: { skipBrowserRedirect?: boolean }
): Promise<AuthOAuthConsentResponse> {
try {
return await this._useSession(async (result) => {
const {
data: { session },
error: sessionError,
} = result

if (sessionError) {
return { data: null, error: sessionError }
}

if (!session) {
return { data: null, error: new AuthSessionMissingError() }
}

const response = await _request(
this.fetch,
'POST',
`${this.url}/oauth/authorizations/${authorizationId}/consent`,
{
headers: this.headers,
jwt: session.access_token,
body: { action: 'approve' },
xform: (data: any) => ({ data, error: null }),
}
)

if (response.data && response.data.redirect_url) {
// Automatically redirect in browser unless skipBrowserRedirect is true
if (isBrowser() && !options?.skipBrowserRedirect) {
window.location.assign(response.data.redirect_url)
}
}

return response
})
} catch (error) {
if (isAuthError(error)) {
return { data: null, error }
}

throw error
}
}

/**
* Denies an OAuth authorization request.
* Only relevant when the OAuth 2.1 server is enabled in Supabase Auth.
*/
private async _denyAuthorization(
authorizationId: string,
options?: { skipBrowserRedirect?: boolean }
): Promise<AuthOAuthConsentResponse> {
try {
return await this._useSession(async (result) => {
const {
data: { session },
error: sessionError,
} = result

if (sessionError) {
return { data: null, error: sessionError }
}

if (!session) {
return { data: null, error: new AuthSessionMissingError() }
}

const response = await _request(
this.fetch,
'POST',
`${this.url}/oauth/authorizations/${authorizationId}/consent`,
{
headers: this.headers,
jwt: session.access_token,
body: { action: 'deny' },
xform: (data: any) => ({ data, error: null }),
}
)

if (response.data && response.data.redirect_url) {
// Automatically redirect in browser unless skipBrowserRedirect is true
if (isBrowser() && !options?.skipBrowserRedirect) {
window.location.assign(response.data.redirect_url)
}
}

return response
})
} catch (error) {
if (isAuthError(error)) {
return { data: null, error }
}

throw error
}
}

private async fetchJwk(kid: string, jwks: { keys: JWK[] } = { keys: [] }): Promise<JWK | null> {
// try fetching from the supplied jwks
let jwk = jwks.keys.find((key) => key.kid === kid)
Expand Down
100 changes: 100 additions & 0 deletions packages/core/auth-js/src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1590,3 +1590,103 @@ export interface GoTrueAdminOAuthApi {
*/
regenerateClientSecret(clientId: string): Promise<OAuthClientResponse>
}

/**
* OAuth client details in an authorization request.
* Only relevant when the OAuth 2.1 server is enabled in Supabase Auth.
*/
export type OAuthAuthorizationClient = {
/** Unique identifier for the OAuth client (UUID) */
client_id: string
/** Human-readable name of the OAuth client */
client_name: string
/** URI of the OAuth client's website */
client_uri: string
/** URI of the OAuth client's logo */
logo_uri: string
}

/**
* OAuth authorization details for the consent flow.
* Only relevant when the OAuth 2.1 server is enabled in Supabase Auth.
*/
export type OAuthAuthorizationDetails = {
/** The authorization ID */
authorization_id: string
/** Redirect URI - present if user already consented (can be used to trigger immediate redirect) */
redirect_uri?: string
/** OAuth client requesting authorization */
client: OAuthAuthorizationClient
/** User object associated with the authorization */
user: {
/** User ID (UUID) */
id: string
/** User email */
email: string
}
/** Space-separated list of requested scopes */
scope: string
Comment on lines +1619 to +1628

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can these values be optional/undefined? I ask because I see them tagged with omitempty but I can't seem to find a case they would be

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point and yeah ideally they should always exists. The context on why we have omitempty is: supabase/auth#2107 (comment)

}

/**
* Response type for getting OAuth authorization details.
* Only relevant when the OAuth 2.1 server is enabled in Supabase Auth.
*/
export type AuthOAuthAuthorizationDetailsResponse = RequestResult<OAuthAuthorizationDetails>

/**
* Response type for OAuth consent decision (approve/deny).
* Only relevant when the OAuth 2.1 server is enabled in Supabase Auth.
*/
export type AuthOAuthConsentResponse = RequestResult<{
/** URL to redirect the user back to the OAuth client */
redirect_url: string
}>

/**
* Contains all OAuth 2.1 authorization server user-facing methods.
* Only relevant when the OAuth 2.1 server is enabled in Supabase Auth.
*
* These methods are used to implement the consent page.
*/
export interface AuthOAuthServerApi {
/**
* Retrieves details about an OAuth authorization request.
* Used to display consent information to the user.
* Only relevant when the OAuth 2.1 server is enabled in Supabase Auth.
*
* @param authorizationId - The authorization ID from the authorization request
* @param options - Optional parameters including skipBrowserRedirect
* @returns Authorization details including client info and requested scopes
*/
getAuthorizationDetails(
authorizationId: string,
options?: { skipBrowserRedirect?: boolean }
): Promise<AuthOAuthAuthorizationDetailsResponse>

/**
* Approves an OAuth authorization request.
* Only relevant when the OAuth 2.1 server is enabled in Supabase Auth.
*
* @param authorizationId - The authorization ID to approve
* @param options - Optional parameters including skipBrowserRedirect
* @returns Redirect URL to send the user back to the OAuth client
*/
approveAuthorization(
authorizationId: string,
options?: { skipBrowserRedirect?: boolean }
): Promise<AuthOAuthConsentResponse>

/**
* Denies an OAuth authorization request.
* Only relevant when the OAuth 2.1 server is enabled in Supabase Auth.
*
* @param authorizationId - The authorization ID to deny
* @param options - Optional parameters including skipBrowserRedirect
* @returns Redirect URL to send the user back to the OAuth client
*/
denyAuthorization(
authorizationId: string,
options?: { skipBrowserRedirect?: boolean }
): Promise<AuthOAuthConsentResponse>
}