Compare commits

...

4 Commits

Author SHA1 Message Date
David Barroso
97a04c0eb2 asd 2025-10-17 09:43:47 +02:00
David Barroso
9d86c03e58 asd 2025-10-17 09:27:46 +02:00
David Barroso
3f69da2ffe feat(auth): store oauth provider's token in a cookie and added endpoint to retrieve them 2025-10-17 09:15:40 +02:00
github-actions[bot]
8ce9705b17 release(services/storage): 0.8.2 (#3585)
Co-authored-by: dbarrosop <dbarrosop@users.noreply.github.com>
2025-10-15 14:51:31 +02:00
17 changed files with 1003 additions and 220 deletions

View File

@@ -368,6 +368,7 @@
"reference/auth/post-user-mfa",
"reference/auth/post-user-password",
"reference/auth/post-user-password-reset",
"reference/auth/get-user-provider-{provider}-tokens",
"reference/auth/post-user-webauthn-add",
"reference/auth/post-user-webauthn-verify",
"reference/auth/get-verify",

View File

@@ -610,7 +610,7 @@ paths:
/signin/provider/{provider}/callback:
get:
summary: OAuth2 provider callback endpoint
description: Handles the callback from OAuth2 providers after user authorization. Processes the authorization code and creates a user session.
description: Handles the callback from OAuth2 providers after user authorization. Processes the authorization code and creates a user session. A cookie with token information is set upon successful authentication. This cookie can be retrieved via the getProviderTokens endpoint
operationId: signInProviderCallbackGet
tags:
- authentication
@@ -669,6 +669,10 @@ paths:
headers:
Location:
$ref: "#/components/headers/RedirectLocation"
Set-Cookie:
description: Sets cookie with token information
schema:
type: string
content: {}
default:
content:
@@ -730,6 +734,10 @@ paths:
headers:
Location:
$ref: "#/components/headers/RedirectLocation"
Set-Cookie:
description: Sets cookie with token information
schema:
type: string
content: {}
default:
content:
@@ -1190,6 +1198,42 @@ paths:
$ref: "#/components/schemas/ErrorResponse"
description: "An error occurred while processing the request"
/user/provider/{provider}/tokens:
get:
summary: Get OAuth2 provider tokens from cookies
description: Retrieves the OAuth2 provider access and refresh tokens from cookies set during the OAuth2 callback. This endpoint must be called immediately after completing the OAuth2 flow to ensure the tokens are correct. The cookies will be removed after calling this endpoint.
operationId: getProviderTokens
tags:
- authentication
security:
- BearerAuth: []
parameters:
- $ref: "#/components/parameters/SignInProvider"
- name: Cookie
in: header
required: true
description: Cookies containing ProviderRefreshToken and ProviderAccessToken
schema:
type: string
responses:
"200":
description: Successfully retrieved provider tokens
content:
application/json:
schema:
$ref: "#/components/schemas/GetProviderTokensResponse"
headers:
Set-Cookie:
description: Removes cookie with token information
schema:
type: string
default:
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
description: "An error occurred while processing the request"
/user/webauthn/add:
post:
summary: Initialize adding of a new webauthn security key
@@ -1607,6 +1651,24 @@ components:
- message
- error
GetProviderTokensResponse:
type: object
description: "OAuth2 provider tokens retrieved from cookies"
additionalProperties: false
properties:
accessToken:
type: string
description: "OAuth2 provider access token"
example: "ya29.a0AfH6SMBx..."
refreshToken:
type: string
nullable: true
description: "OAuth2 provider refresh token (if available)"
example: "1//0gK8..."
required:
- accessToken
IdTokenProvider:
type: string
additionalProperties: false
@@ -2512,7 +2574,6 @@ components:
- facebook
- windowslive
- twitter
deprecated: true
TicketQuery:
in: query

View File

@@ -0,0 +1,5 @@
---
title: "getProviderTokens"
openapi: get /user/provider/{provider}/tokens
sidebarTitle: /user/provider/{provider}/tokens
---

View File

@@ -468,6 +468,30 @@ This method may return different T based on the response code:
`Promise`&lt;[`FetchResponse`](./fetch#fetchresponse)&lt;[`JWKSet`](#jwkset)&gt;&gt;
#### getProviderTokens()
```ts
getProviderTokens(provider: SignInProvider, options?: RequestInit): Promise<FetchResponse<GetProviderTokensResponse>>;
```
Summary: Get OAuth2 provider tokens from cookies
Retrieves the OAuth2 provider access and refresh tokens from cookies set during the OAuth2 callback. This endpoint must be called immediately after completing the OAuth2 flow to ensure the tokens are correct. The cookies will be removed after calling this endpoint.
This method may return different T based on the response code:
- 200: GetProviderTokensResponse
##### Parameters
| Parameter | Type |
| ---------- | ----------------------------------- |
| `provider` | [`SignInProvider`](#signinprovider) |
| `options?` | `RequestInit` |
##### Returns
`Promise`&lt;[`FetchResponse`](./fetch#fetchresponse)&lt;[`GetProviderTokensResponse`](#getprovidertokensresponse)&gt;&gt;
#### getUser()
```ts
@@ -1442,6 +1466,33 @@ status: number
---
## GetProviderTokensResponse
OAuth2 provider tokens retrieved from cookies
### Properties
#### accessToken
```ts
accessToken: string
```
(`string`) - OAuth2 provider access token
- Example - `"ya29.a0AfH6SMBx..."`
#### refreshToken?
```ts
optional refreshToken: string;
```
OAuth2 provider refresh token (if available)
Example - `"1//0gK8..."`
---
## GetVersionResponse200
### Properties

View File

@@ -454,7 +454,7 @@ Example - `"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."`
##### Inherited from
[`Session`](./auth#session).[`accessToken`](./auth#accesstoken)
[`Session`](./auth#session).[`accessToken`](./auth#accesstoken-1)
#### accessTokenExpiresIn
@@ -490,7 +490,7 @@ Pattern - \b[0-9a-f]{8}\b-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-\b[0-9a-f]{12}\b
##### Inherited from
[`Session`](./auth#session).[`refreshToken`](./auth#refreshtoken-3)
[`Session`](./auth#session).[`refreshToken`](./auth#refreshtoken-4)
#### refreshTokenId

View File

@@ -363,6 +363,25 @@ export interface ErrorResponse {
error: ErrorResponseError;
}
/**
* OAuth2 provider tokens retrieved from cookies
@property accessToken (`string`) - OAuth2 provider access token
* Example - `"ya29.a0AfH6SMBx..."`
@property refreshToken? (`string`) - OAuth2 provider refresh token (if available)
* Example - `"1//0gK8..."`*/
export interface GetProviderTokensResponse {
/**
* OAuth2 provider access token
* Example - `"ya29.a0AfH6SMBx..."`
*/
accessToken: string;
/**
* OAuth2 provider refresh token (if available)
* Example - `"1//0gK8..."`
*/
refreshToken?: string;
}
/**
*
*/
@@ -2008,6 +2027,18 @@ export interface Client {
options?: RequestInit,
): Promise<FetchResponse<OKResponse>>;
/**
Summary: Get OAuth2 provider tokens from cookies
Retrieves the OAuth2 provider access and refresh tokens from cookies set during the OAuth2 callback. This endpoint must be called immediately after completing the OAuth2 flow to ensure the tokens are correct. The cookies will be removed after calling this endpoint.
This method may return different T based on the response code:
- 200: GetProviderTokensResponse
*/
getProviderTokens(
provider: SignInProvider,
options?: RequestInit,
): Promise<FetchResponse<GetProviderTokensResponse>>;
/**
Summary: Initialize adding of a new webauthn security key
Start the process of adding a new WebAuthn security key to the user's account. Returns a challenge that must be completed by the user's authenticator device. Requires elevated permissions.
@@ -3184,6 +3215,39 @@ export const createAPIClient = (
} as FetchResponse<OKResponse>;
};
const getProviderTokens = async (
provider: SignInProvider,
options?: RequestInit,
): Promise<FetchResponse<GetProviderTokensResponse>> => {
const url = `${baseURL}/user/provider/${provider}/tokens`;
const res = await fetch(url, {
...options,
method: "GET",
headers: {
...options?.headers,
},
});
if (res.status >= 300) {
const responseBody = [412].includes(res.status) ? null : await res.text();
const payload: unknown = responseBody ? JSON.parse(responseBody) : {};
throw new FetchError(payload, res.status, res.headers);
}
const responseBody = [204, 205, 304].includes(res.status)
? null
: await res.text();
const payload: GetProviderTokensResponse = responseBody
? JSON.parse(responseBody)
: {};
return {
body: payload,
status: res.status,
headers: res.headers,
} as FetchResponse<GetProviderTokensResponse>;
};
const addSecurityKey = async (
options?: RequestInit,
): Promise<FetchResponse<PublicKeyCredentialCreationOptions>> => {
@@ -3340,6 +3404,7 @@ export const createAPIClient = (
verifyChangeUserMfa,
changeUserPassword,
sendPasswordResetEmail,
getProviderTokens,
addSecurityKey,
verifyAddSecurityKey,
verifyTicketURL,

View File

@@ -610,7 +610,7 @@ paths:
/signin/provider/{provider}/callback:
get:
summary: OAuth2 provider callback endpoint
description: Handles the callback from OAuth2 providers after user authorization. Processes the authorization code and creates a user session.
description: Handles the callback from OAuth2 providers after user authorization. Processes the authorization code and creates a user session. A cookie with token information is set upon successful authentication. This cookie can be retrieved via the getProviderTokens endpoint
operationId: signInProviderCallbackGet
tags:
- authentication
@@ -669,6 +669,10 @@ paths:
headers:
Location:
$ref: "#/components/headers/RedirectLocation"
Set-Cookie:
description: Sets cookie with token information
schema:
type: string
content: {}
default:
content:
@@ -730,6 +734,10 @@ paths:
headers:
Location:
$ref: "#/components/headers/RedirectLocation"
Set-Cookie:
description: Sets cookie with token information
schema:
type: string
content: {}
default:
content:
@@ -1190,6 +1198,42 @@ paths:
$ref: "#/components/schemas/ErrorResponse"
description: "An error occurred while processing the request"
/user/provider/{provider}/tokens:
get:
summary: Get OAuth2 provider tokens from cookies
description: Retrieves the OAuth2 provider access and refresh tokens from cookies set during the OAuth2 callback. This endpoint must be called immediately after completing the OAuth2 flow to ensure the tokens are correct. The cookies will be removed after calling this endpoint.
operationId: getProviderTokens
tags:
- authentication
security:
- BearerAuth: []
parameters:
- $ref: "#/components/parameters/SignInProvider"
- name: Cookie
in: header
required: true
description: Cookies containing ProviderRefreshToken and ProviderAccessToken
schema:
type: string
responses:
"200":
description: Successfully retrieved provider tokens
content:
application/json:
schema:
$ref: "#/components/schemas/GetProviderTokensResponse"
headers:
Set-Cookie:
description: Removes cookie with token information
schema:
type: string
default:
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
description: "An error occurred while processing the request"
/user/webauthn/add:
post:
summary: Initialize adding of a new webauthn security key
@@ -1607,6 +1651,24 @@ components:
- message
- error
GetProviderTokensResponse:
type: object
description: "OAuth2 provider tokens retrieved from cookies"
additionalProperties: false
properties:
accessToken:
type: string
description: "OAuth2 provider access token"
example: "ya29.a0AfH6SMBx..."
refreshToken:
type: string
nullable: true
description: "OAuth2 provider refresh token (if available)"
example: "1//0gK8..."
required:
- accessToken
IdTokenProvider:
type: string
additionalProperties: false
@@ -2512,7 +2574,6 @@ components:
- facebook
- windowslive
- twitter
deprecated: true
TicketQuery:
in: query

View File

@@ -133,6 +133,9 @@ type ServerInterface interface {
// Request password reset
// (POST /user/password/reset)
SendPasswordResetEmail(c *gin.Context)
// Get OAuth2 provider tokens from cookies
// (GET /user/provider/{provider}/tokens)
GetProviderTokens(c *gin.Context, provider GetProviderTokensParamsProvider, params GetProviderTokensParams)
// Initialize adding of a new webauthn security key
// (POST /user/webauthn/add)
AddSecurityKey(c *gin.Context)
@@ -821,6 +824,59 @@ func (siw *ServerInterfaceWrapper) SendPasswordResetEmail(c *gin.Context) {
siw.Handler.SendPasswordResetEmail(c)
}
// GetProviderTokens operation middleware
func (siw *ServerInterfaceWrapper) GetProviderTokens(c *gin.Context) {
var err error
// ------------- Path parameter "provider" -------------
var provider GetProviderTokensParamsProvider
err = runtime.BindStyledParameterWithOptions("simple", "provider", c.Param("provider"), &provider, runtime.BindStyledParameterOptions{Explode: false, Required: true})
if err != nil {
siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter provider: %w", err), http.StatusBadRequest)
return
}
c.Set(BearerAuthScopes, []string{})
// Parameter object where we will unmarshal all parameters from the context
var params GetProviderTokensParams
headers := c.Request.Header
// ------------- Required header parameter "Cookie" -------------
if valueList, found := headers[http.CanonicalHeaderKey("Cookie")]; found {
var Cookie string
n := len(valueList)
if n != 1 {
siw.ErrorHandler(c, fmt.Errorf("Expected one value for Cookie, got %d", n), http.StatusBadRequest)
return
}
err = runtime.BindStyledParameterWithOptions("simple", "Cookie", valueList[0], &Cookie, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationHeader, Explode: false, Required: true})
if err != nil {
siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter Cookie: %w", err), http.StatusBadRequest)
return
}
params.Cookie = Cookie
} else {
siw.ErrorHandler(c, fmt.Errorf("Header parameter Cookie is required, but not found"), http.StatusBadRequest)
return
}
for _, middleware := range siw.HandlerMiddlewares {
middleware(c)
if c.IsAborted() {
return
}
}
siw.Handler.GetProviderTokens(c, provider, params)
}
// AddSecurityKey operation middleware
func (siw *ServerInterfaceWrapper) AddSecurityKey(c *gin.Context) {
@@ -983,6 +1039,7 @@ func RegisterHandlersWithOptions(router gin.IRouter, si ServerInterface, options
router.POST(options.BaseURL+"/user/mfa", wrapper.VerifyChangeUserMfa)
router.POST(options.BaseURL+"/user/password", wrapper.ChangeUserPassword)
router.POST(options.BaseURL+"/user/password/reset", wrapper.SendPasswordResetEmail)
router.GET(options.BaseURL+"/user/provider/:provider/tokens", wrapper.GetProviderTokens)
router.POST(options.BaseURL+"/user/webauthn/add", wrapper.AddSecurityKey)
router.POST(options.BaseURL+"/user/webauthn/verify", wrapper.VerifyAddSecurityKey)
router.GET(options.BaseURL+"/verify", wrapper.VerifyTicket)
@@ -1550,7 +1607,8 @@ type SignInProviderCallbackGetResponseObject interface {
}
type SignInProviderCallbackGet302ResponseHeaders struct {
Location string
Location string
SetCookie string
}
type SignInProviderCallbackGet302Response struct {
@@ -1559,6 +1617,7 @@ type SignInProviderCallbackGet302Response struct {
func (response SignInProviderCallbackGet302Response) VisitSignInProviderCallbackGetResponse(w http.ResponseWriter) error {
w.Header().Set("Location", fmt.Sprint(response.Headers.Location))
w.Header().Set("Set-Cookie", fmt.Sprint(response.Headers.SetCookie))
w.WriteHeader(302)
return nil
}
@@ -1585,7 +1644,8 @@ type SignInProviderCallbackPostResponseObject interface {
}
type SignInProviderCallbackPost302ResponseHeaders struct {
Location string
Location string
SetCookie string
}
type SignInProviderCallbackPost302Response struct {
@@ -1594,6 +1654,7 @@ type SignInProviderCallbackPost302Response struct {
func (response SignInProviderCallbackPost302Response) VisitSignInProviderCallbackPostResponse(w http.ResponseWriter) error {
w.Header().Set("Location", fmt.Sprint(response.Headers.Location))
w.Header().Set("Set-Cookie", fmt.Sprint(response.Headers.SetCookie))
w.WriteHeader(302)
return nil
}
@@ -2044,6 +2105,44 @@ func (response SendPasswordResetEmaildefaultJSONResponse) VisitSendPasswordReset
return json.NewEncoder(w).Encode(response.Body)
}
type GetProviderTokensRequestObject struct {
Provider GetProviderTokensParamsProvider `json:"provider"`
Params GetProviderTokensParams
}
type GetProviderTokensResponseObject interface {
VisitGetProviderTokensResponse(w http.ResponseWriter) error
}
type GetProviderTokens200ResponseHeaders struct {
SetCookie string
}
type GetProviderTokens200JSONResponse struct {
Body GetProviderTokensResponse
Headers GetProviderTokens200ResponseHeaders
}
func (response GetProviderTokens200JSONResponse) VisitGetProviderTokensResponse(w http.ResponseWriter) error {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Set-Cookie", fmt.Sprint(response.Headers.SetCookie))
w.WriteHeader(200)
return json.NewEncoder(w).Encode(response.Body)
}
type GetProviderTokensdefaultJSONResponse struct {
Body ErrorResponse
StatusCode int
}
func (response GetProviderTokensdefaultJSONResponse) VisitGetProviderTokensResponse(w http.ResponseWriter) error {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(response.StatusCode)
return json.NewEncoder(w).Encode(response.Body)
}
type AddSecurityKeyRequestObject struct {
}
@@ -2276,6 +2375,9 @@ type StrictServerInterface interface {
// Request password reset
// (POST /user/password/reset)
SendPasswordResetEmail(ctx context.Context, request SendPasswordResetEmailRequestObject) (SendPasswordResetEmailResponseObject, error)
// Get OAuth2 provider tokens from cookies
// (GET /user/provider/{provider}/tokens)
GetProviderTokens(ctx context.Context, request GetProviderTokensRequestObject) (GetProviderTokensResponseObject, error)
// Initialize adding of a new webauthn security key
// (POST /user/webauthn/add)
AddSecurityKey(ctx context.Context, request AddSecurityKeyRequestObject) (AddSecurityKeyResponseObject, error)
@@ -3443,6 +3545,34 @@ func (sh *strictHandler) SendPasswordResetEmail(ctx *gin.Context) {
}
}
// GetProviderTokens operation middleware
func (sh *strictHandler) GetProviderTokens(ctx *gin.Context, provider GetProviderTokensParamsProvider, params GetProviderTokensParams) {
var request GetProviderTokensRequestObject
request.Provider = provider
request.Params = params
handler := func(ctx *gin.Context, request interface{}) (interface{}, error) {
return sh.ssi.GetProviderTokens(ctx, request.(GetProviderTokensRequestObject))
}
for _, middleware := range sh.middlewares {
handler = middleware(handler, "GetProviderTokens")
}
response, err := handler(ctx, request)
if err != nil {
ctx.Error(err)
ctx.Status(http.StatusInternalServerError)
} else if validResponse, ok := response.(GetProviderTokensResponseObject); ok {
if err := validResponse.VisitGetProviderTokensResponse(ctx.Writer); err != nil {
ctx.Error(err)
}
} else if response != nil {
ctx.Error(fmt.Errorf("unexpected response type: %T", response))
}
}
// AddSecurityKey operation middleware
func (sh *strictHandler) AddSecurityKey(ctx *gin.Context) {
var request AddSecurityKeyRequestObject
@@ -3556,171 +3686,177 @@ func (sh *strictHandler) GetVersion(ctx *gin.Context) {
// Base64 encoded, gzipped, json marshaled Swagger object
var swaggerSpec = []string{
"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",
"AP//J2Dqrmj4AAA=",
"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",
"q3C1rf8AAA==",
}
// GetSwagger returns the content of the embedded swagger specification file

View File

@@ -206,22 +206,42 @@ const (
// Defines values for SignInProviderCallbackPostParamsProvider.
const (
Apple SignInProviderCallbackPostParamsProvider = "apple"
Azuread SignInProviderCallbackPostParamsProvider = "azuread"
Bitbucket SignInProviderCallbackPostParamsProvider = "bitbucket"
Discord SignInProviderCallbackPostParamsProvider = "discord"
Entraid SignInProviderCallbackPostParamsProvider = "entraid"
Facebook SignInProviderCallbackPostParamsProvider = "facebook"
Github SignInProviderCallbackPostParamsProvider = "github"
Gitlab SignInProviderCallbackPostParamsProvider = "gitlab"
Google SignInProviderCallbackPostParamsProvider = "google"
Linkedin SignInProviderCallbackPostParamsProvider = "linkedin"
Spotify SignInProviderCallbackPostParamsProvider = "spotify"
Strava SignInProviderCallbackPostParamsProvider = "strava"
Twitch SignInProviderCallbackPostParamsProvider = "twitch"
Twitter SignInProviderCallbackPostParamsProvider = "twitter"
Windowslive SignInProviderCallbackPostParamsProvider = "windowslive"
Workos SignInProviderCallbackPostParamsProvider = "workos"
SignInProviderCallbackPostParamsProviderApple SignInProviderCallbackPostParamsProvider = "apple"
SignInProviderCallbackPostParamsProviderAzuread SignInProviderCallbackPostParamsProvider = "azuread"
SignInProviderCallbackPostParamsProviderBitbucket SignInProviderCallbackPostParamsProvider = "bitbucket"
SignInProviderCallbackPostParamsProviderDiscord SignInProviderCallbackPostParamsProvider = "discord"
SignInProviderCallbackPostParamsProviderEntraid SignInProviderCallbackPostParamsProvider = "entraid"
SignInProviderCallbackPostParamsProviderFacebook SignInProviderCallbackPostParamsProvider = "facebook"
SignInProviderCallbackPostParamsProviderGithub SignInProviderCallbackPostParamsProvider = "github"
SignInProviderCallbackPostParamsProviderGitlab SignInProviderCallbackPostParamsProvider = "gitlab"
SignInProviderCallbackPostParamsProviderGoogle SignInProviderCallbackPostParamsProvider = "google"
SignInProviderCallbackPostParamsProviderLinkedin SignInProviderCallbackPostParamsProvider = "linkedin"
SignInProviderCallbackPostParamsProviderSpotify SignInProviderCallbackPostParamsProvider = "spotify"
SignInProviderCallbackPostParamsProviderStrava SignInProviderCallbackPostParamsProvider = "strava"
SignInProviderCallbackPostParamsProviderTwitch SignInProviderCallbackPostParamsProvider = "twitch"
SignInProviderCallbackPostParamsProviderTwitter SignInProviderCallbackPostParamsProvider = "twitter"
SignInProviderCallbackPostParamsProviderWindowslive SignInProviderCallbackPostParamsProvider = "windowslive"
SignInProviderCallbackPostParamsProviderWorkos SignInProviderCallbackPostParamsProvider = "workos"
)
// Defines values for GetProviderTokensParamsProvider.
const (
GetProviderTokensParamsProviderApple GetProviderTokensParamsProvider = "apple"
GetProviderTokensParamsProviderAzuread GetProviderTokensParamsProvider = "azuread"
GetProviderTokensParamsProviderBitbucket GetProviderTokensParamsProvider = "bitbucket"
GetProviderTokensParamsProviderDiscord GetProviderTokensParamsProvider = "discord"
GetProviderTokensParamsProviderEntraid GetProviderTokensParamsProvider = "entraid"
GetProviderTokensParamsProviderFacebook GetProviderTokensParamsProvider = "facebook"
GetProviderTokensParamsProviderGithub GetProviderTokensParamsProvider = "github"
GetProviderTokensParamsProviderGitlab GetProviderTokensParamsProvider = "gitlab"
GetProviderTokensParamsProviderGoogle GetProviderTokensParamsProvider = "google"
GetProviderTokensParamsProviderLinkedin GetProviderTokensParamsProvider = "linkedin"
GetProviderTokensParamsProviderSpotify GetProviderTokensParamsProvider = "spotify"
GetProviderTokensParamsProviderStrava GetProviderTokensParamsProvider = "strava"
GetProviderTokensParamsProviderTwitch GetProviderTokensParamsProvider = "twitch"
GetProviderTokensParamsProviderTwitter GetProviderTokensParamsProvider = "twitter"
GetProviderTokensParamsProviderWindowslive GetProviderTokensParamsProvider = "windowslive"
GetProviderTokensParamsProviderWorkos GetProviderTokensParamsProvider = "workos"
)
// Defines values for VerifyTicketParamsType.
@@ -366,6 +386,15 @@ type ErrorResponse struct {
// ErrorResponseError Error code identifying the specific application error
type ErrorResponseError string
// GetProviderTokensResponse OAuth2 provider tokens retrieved from cookies
type GetProviderTokensResponse struct {
// AccessToken OAuth2 provider access token
AccessToken string `json:"accessToken"`
// RefreshToken OAuth2 provider refresh token (if available)
RefreshToken *string `json:"refreshToken"`
}
// IdTokenProvider defines model for IdTokenProvider.
type IdTokenProvider string
@@ -907,6 +936,15 @@ type SignInProviderCallbackPostFormdataBody struct {
// SignInProviderCallbackPostParamsProvider defines parameters for SignInProviderCallbackPost.
type SignInProviderCallbackPostParamsProvider string
// GetProviderTokensParams defines parameters for GetProviderTokens.
type GetProviderTokensParams struct {
// Cookie Cookies containing ProviderRefreshToken and ProviderAccessToken
Cookie string `json:"Cookie"`
}
// GetProviderTokensParamsProvider defines parameters for GetProviderTokens.
type GetProviderTokensParamsProvider string
// VerifyTicketParams defines parameters for VerifyTicket.
type VerifyTicketParams struct {
// Ticket Ticket

View File

@@ -224,6 +224,10 @@ func (response ErrorResponse) VisitGetUserResponse(w http.ResponseWriter) error
return response.visit(w)
}
func (response ErrorResponse) VisitGetProviderTokensResponse(w http.ResponseWriter) error {
return response.visit(w)
}
func (response ErrorResponse) VisitVerifySignInPasswordlessSmsResponse(
w http.ResponseWriter,
) error {

View File

@@ -0,0 +1,86 @@
package controller
import (
"context"
"encoding/json"
"log/slog"
"net/http"
"net/url"
"strings"
"github.com/nhost/nhost/services/auth/go/api"
"github.com/nhost/nhost/services/auth/go/middleware"
)
func getCookie(
ctx context.Context, name string, cookieHeader string, logger *slog.Logger,
) *http.Cookie {
cookies := strings.SplitSeq(cookieHeader, ";")
for c := range cookies {
cookie, err := http.ParseSetCookie(c)
if err != nil {
logger.WarnContext(ctx, "error parsing cookie", logError(err))
continue
}
if cookie.Name == name {
return cookie
}
}
return nil
}
func (ctrl *Controller) GetProviderTokens( //nolint:ireturn
ctx context.Context,
req api.GetProviderTokensRequestObject,
) (api.GetProviderTokensResponseObject, error) {
logger := middleware.LoggerFromContext(ctx)
logger = logger.With("provider", req.Provider)
_, apiErr := ctrl.wf.GetJWTInContext(ctx, logger)
if apiErr != nil {
return ctrl.sendError(apiErr), nil
}
cookieName := string(req.Provider) + cookieSuffixProviderToken
cookie := getCookie(ctx, cookieName, req.Params.Cookie, logger)
var (
accessToken string
refreshToken *string
)
if cookie != nil {
v, err := url.QueryUnescape(cookie.Value)
if err != nil {
logger.WarnContext(ctx, "error unescaping cookie value", logError(err))
return ctrl.sendError(ErrInternalServerError), nil
}
var m map[string]any
if err := json.NewDecoder(strings.NewReader(v)).Decode(&m); err != nil {
logger.WarnContext(ctx, "error decoding cookie value", logError(err))
return ctrl.sendError(ErrInternalServerError), nil
}
accessToken, _ = m["accessToken"].(string)
rt, _ := m["refreshToken"].(string)
if rt != "" {
refreshToken = &rt
}
}
return api.GetProviderTokens200JSONResponse{
Body: api.GetProviderTokensResponse{
AccessToken: accessToken,
RefreshToken: refreshToken,
},
Headers: api.GetProviderTokens200ResponseHeaders{
SetCookie: providerCookies(
string(req.Provider), "", "", ctrl.config.UseSecureCookies(), -1,
),
},
}, nil
}

View File

@@ -0,0 +1,195 @@
package controller_test
import (
"net/http"
"testing"
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/nhost/nhost/services/auth/go/api"
"github.com/nhost/nhost/services/auth/go/controller"
"github.com/nhost/nhost/services/auth/go/controller/mock"
"go.uber.org/mock/gomock"
)
func TestGetProviderTokens(t *testing.T) {
t.Parallel()
jwtTokenFn := func() *jwt.Token {
return &jwt.Token{
Raw: "",
Method: jwt.SigningMethodHS256,
Header: map[string]any{
"alg": "HS256",
"typ": "JWT",
},
Claims: jwt.MapClaims{
"exp": float64(time.Now().Add(900 * time.Second).Unix()),
"https://hasura.io/jwt/claims": map[string]any{
"x-hasura-allowed-roles": []any{"anonymous"},
"x-hasura-default-role": "anonymous",
"x-hasura-user-id": "db477732-48fa-4289-b694-2886a646b6eb",
"x-hasura-user-is-anonymous": "true",
},
"iat": float64(time.Now().Unix()),
"iss": "hasura-auth",
"sub": "db477732-48fa-4289-b694-2886a646b6eb",
},
Signature: []byte{},
Valid: true,
}
}
cases := []testRequest[api.GetProviderTokensRequestObject, api.GetProviderTokensResponseObject]{
{
name: "success - one cookie",
config: getConfig,
db: func(ctrl *gomock.Controller) controller.DBClient {
mock := mock.NewMockDBClient(ctrl)
return mock
},
request: api.GetProviderTokensRequestObject{
Provider: "fake",
Params: api.GetProviderTokensParams{
Cookie: "fakeProviderTokens=%7B%22accessToken%22%3A%22valid-accesstoken-1%22%2C%22refreshToken%22%3A%22valid-refreshtoken-1%22%7D;", //nolint:lll
},
},
expectedResponse: api.GetProviderTokens200JSONResponse{
Headers: api.GetProviderTokens200ResponseHeaders{
SetCookie: "fakeProviderTokens=; Path=/; Max-Age=0; HttpOnly; Secure; SameSite=Strict",
},
Body: api.GetProviderTokensResponse{
AccessToken: "valid-accesstoken-1",
RefreshToken: ptr("valid-refreshtoken-1"),
},
},
expectedJWT: nil,
jwtTokenFn: jwtTokenFn,
getControllerOpts: nil,
},
{
name: "success - more cookies",
config: getConfig,
db: func(ctrl *gomock.Controller) controller.DBClient {
mock := mock.NewMockDBClient(ctrl)
return mock
},
request: api.GetProviderTokensRequestObject{
Provider: "fake",
Params: api.GetProviderTokensParams{
Cookie: "someCookie=; fakeProviderTokens=%7B%22accessToken%22%3A%22valid-accesstoken-1%22%2C%22refreshToken%22%3A%22valid-refreshtoken-1%22%7D;", //nolint:lll
},
},
expectedResponse: api.GetProviderTokens200JSONResponse{
Headers: api.GetProviderTokens200ResponseHeaders{
SetCookie: "fakeProviderTokens=; Path=/; Max-Age=0; HttpOnly; Secure; SameSite=Strict",
},
Body: api.GetProviderTokensResponse{
AccessToken: "valid-accesstoken-1",
RefreshToken: ptr("valid-refreshtoken-1"),
},
},
expectedJWT: nil,
jwtTokenFn: jwtTokenFn,
getControllerOpts: nil,
},
{
name: "success - missing cookie",
config: getConfig,
db: func(ctrl *gomock.Controller) controller.DBClient {
mock := mock.NewMockDBClient(ctrl)
return mock
},
request: api.GetProviderTokensRequestObject{
Provider: "fake",
Params: api.GetProviderTokensParams{
Cookie: "someCookie=;",
},
},
expectedResponse: api.GetProviderTokens200JSONResponse{
Headers: api.GetProviderTokens200ResponseHeaders{
SetCookie: "fakeProviderTokens=; Path=/; Max-Age=0; HttpOnly; Secure; SameSite=Strict",
},
Body: api.GetProviderTokensResponse{
AccessToken: "",
RefreshToken: nil,
},
},
expectedJWT: nil,
jwtTokenFn: jwtTokenFn,
getControllerOpts: nil,
},
{
name: "success - no cookies",
config: getConfig,
db: func(ctrl *gomock.Controller) controller.DBClient {
mock := mock.NewMockDBClient(ctrl)
return mock
},
request: api.GetProviderTokensRequestObject{
Provider: "fake",
Params: api.GetProviderTokensParams{
Cookie: "",
},
},
expectedResponse: api.GetProviderTokens200JSONResponse{
Headers: api.GetProviderTokens200ResponseHeaders{
SetCookie: "fakeProviderTokens=; Path=/; Max-Age=0; HttpOnly; Secure; SameSite=Strict",
},
Body: api.GetProviderTokensResponse{
AccessToken: "",
RefreshToken: nil,
},
},
expectedJWT: nil,
jwtTokenFn: jwtTokenFn,
getControllerOpts: nil,
},
{
name: "missing jwt",
config: getConfig,
db: func(ctrl *gomock.Controller) controller.DBClient {
mock := mock.NewMockDBClient(ctrl)
return mock
},
request: api.GetProviderTokensRequestObject{
Provider: "fake",
Params: api.GetProviderTokensParams{
Cookie: "someCookie=;",
},
},
expectedResponse: controller.ErrorResponse{
Error: "invalid-request",
Message: "The request payload is incorrect",
Status: http.StatusBadRequest,
},
expectedJWT: nil,
jwtTokenFn: nil,
getControllerOpts: nil,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
c, jwtGetter := getController(t, ctrl, tc.config, tc.db, tc.getControllerOpts...)
ctx := t.Context()
if tc.jwtTokenFn != nil {
ctx = jwtGetter.ToContext(t.Context(), tc.jwtTokenFn())
}
assertRequest(
ctx, t, c.GetProviderTokens, tc.request, tc.expectedResponse,
)
})
}
}

View File

@@ -2,7 +2,9 @@ package controller
import (
"context"
"encoding/json"
"log/slog"
"net/http"
"net/url"
"github.com/nhost/nhost/services/auth/go/api"
@@ -11,6 +13,11 @@ import (
"github.com/nhost/nhost/services/auth/go/providers"
)
const (
sevenDaysInSeconds = 7 * 24 * 60 * 60
cookieSuffixProviderToken = "ProviderTokens"
)
type providerCallbackData struct {
State string
Provider string
@@ -78,15 +85,17 @@ func (ctrl *Controller) signinProviderProviderCallbackOauthFlow(
ctx context.Context,
req providerCallbackData,
logger *slog.Logger,
) (oidc.Profile, *APIError) {
) (oidc.Profile, string, string, *APIError) {
p := ctrl.Providers.Get(req.Provider)
if p == nil {
logger.ErrorContext(ctx, "provider not enabled")
return oidc.Profile{}, ErrDisabledEndpoint
return oidc.Profile{}, "", "", ErrDisabledEndpoint
}
var profile oidc.Profile
var (
profile oidc.Profile
refreshToken, accessToken string
)
switch {
case p.IsOauth1():
accessTokenValue, accessTokenSecret, err := p.Oauth1().AccessToken(
@@ -94,40 +103,45 @@ func (ctrl *Controller) signinProviderProviderCallbackOauthFlow(
)
if err != nil {
logger.ErrorContext(ctx, "failed to request token", logError(err))
return oidc.Profile{}, ErrOauthProfileFetchFailed
return oidc.Profile{}, "", "", ErrOauthProfileFetchFailed
}
profile, err = p.Oauth1().GetProfile(ctx, accessTokenValue, accessTokenSecret)
if err != nil {
logger.ErrorContext(ctx, "failed to get user info", logError(err))
return oidc.Profile{}, ErrOauthProfileFetchFailed
return oidc.Profile{}, "", "", ErrOauthProfileFetchFailed
}
accessToken = accessTokenValue
default:
token, err := p.Oauth2().Exchange(ctx, deptr(req.Code))
if err != nil {
logger.ErrorContext(ctx, "failed to exchange token", logError(err))
return oidc.Profile{}, ErrOauthTokenExchangeFailed
return oidc.Profile{}, "", "", ErrOauthTokenExchangeFailed
}
profile, err = p.Oauth2().GetProfile(ctx, token.AccessToken, req.IDToken, req.Extras)
if err != nil {
logger.ErrorContext(ctx, "failed to get user info", logError(err))
return oidc.Profile{}, ErrOauthProfileFetchFailed
return oidc.Profile{}, "", "", ErrOauthProfileFetchFailed
}
refreshToken = token.RefreshToken
accessToken = token.AccessToken
}
if profile.ProviderUserID == "" {
logger.ErrorContext(ctx, "provider user id is empty")
return oidc.Profile{}, ErrOauthProfileFetchFailed
return oidc.Profile{}, "", "", ErrOauthProfileFetchFailed
}
return profile, nil
return profile, refreshToken, accessToken, nil
}
func (ctrl *Controller) signinProviderProviderCallback(
ctx context.Context,
req providerCallbackData,
) (*url.URL, *APIError) {
) (*url.URL, string, string, *APIError) {
logger := middleware.LoggerFromContext(ctx)
options, connnect, redirectTo, apiErr := ctrl.signinProviderProviderCallbackValidate(
@@ -136,26 +150,30 @@ func (ctrl *Controller) signinProviderProviderCallback(
logger,
)
if apiErr != nil {
return redirectTo, apiErr
return redirectTo, "", "", apiErr
}
profile, apiErr := ctrl.signinProviderProviderCallbackOauthFlow(ctx, req, logger)
profile, provRefreshToken, provAccessToken, apiErr := ctrl.signinProviderProviderCallbackOauthFlow(
ctx,
req,
logger,
)
if apiErr != nil {
return redirectTo, apiErr
return redirectTo, "", "", apiErr
}
if connnect != nil {
if apiErr := ctrl.signinProviderProviderCallbackConnect(
ctx, *connnect, req.Provider, profile, logger,
); apiErr != nil {
return redirectTo, apiErr
return redirectTo, "", "", apiErr
}
} else {
session, apiErr := ctrl.providerSignInFlow(
ctx, profile, req.Provider, options, logger,
)
if apiErr != nil {
return redirectTo, apiErr
return redirectTo, "", "", apiErr
}
if session != nil {
@@ -165,7 +183,7 @@ func (ctrl *Controller) signinProviderProviderCallback(
}
}
return redirectTo, nil
return redirectTo, provRefreshToken, provAccessToken, nil
}
func (ctrl *Controller) SignInProviderCallbackGet( //nolint:ireturn
@@ -185,7 +203,7 @@ func (ctrl *Controller) SignInProviderCallbackGet( //nolint:ireturn
ErrorURI: req.Params.ErrorUri,
}
redirectTo, apiErr := ctrl.signinProviderProviderCallback(
redirectTo, provRefreshToken, provAccessToken, apiErr := ctrl.signinProviderProviderCallback(
ctx,
providerCallbackData,
)
@@ -196,6 +214,13 @@ func (ctrl *Controller) SignInProviderCallbackGet( //nolint:ireturn
return api.SignInProviderCallbackGet302Response{
Headers: api.SignInProviderCallbackGet302ResponseHeaders{
Location: redirectTo.String(),
SetCookie: providerCookies(
string(req.Provider),
provRefreshToken,
provAccessToken,
ctrl.config.UseSecureCookies(),
sevenDaysInSeconds,
),
},
}, nil
}
@@ -241,6 +266,36 @@ func (ctrl *Controller) signinProviderProviderCallbackConnect(
return nil
}
func providerCookies(
providerName, refreshToken, accessToken string, secure bool, maxAge int,
) string {
var b []byte
if accessToken == "" && refreshToken == "" {
b = []byte(``)
} else {
var err error
b, err = json.Marshal(map[string]string{
"refreshToken": refreshToken,
"accessToken": accessToken,
})
if err != nil {
// This should never happen
panic(err)
}
}
return (&http.Cookie{ //nolint:exhaustruct
Name: providerName + cookieSuffixProviderToken,
Value: url.QueryEscape(string(b)),
Path: "/",
HttpOnly: true,
Secure: secure,
SameSite: http.SameSiteStrictMode,
MaxAge: maxAge,
}).String()
}
func (ctrl *Controller) SignInProviderCallbackPost( //nolint:ireturn
ctx context.Context,
req api.SignInProviderCallbackPostRequestObject,
@@ -260,7 +315,7 @@ func (ctrl *Controller) SignInProviderCallbackPost( //nolint:ireturn
ErrorURI: req.Body.ErrorUri,
}
redirectTo, apiErr := ctrl.signinProviderProviderCallback(
redirectTo, provRefreshToken, provAccessToken, apiErr := ctrl.signinProviderProviderCallback(
ctx,
providerCallbackData,
)
@@ -271,6 +326,15 @@ func (ctrl *Controller) SignInProviderCallbackPost( //nolint:ireturn
return api.SignInProviderCallbackPost302Response{
Headers: api.SignInProviderCallbackPost302ResponseHeaders{
Location: redirectTo.String(),
SetCookie: providerCookies(
string(
req.Provider,
),
provRefreshToken,
provAccessToken,
ctrl.config.UseSecureCookies(),
sevenDaysInSeconds,
),
},
}, nil
}

View File

@@ -131,7 +131,8 @@ func TestSignInProviderCallback(t *testing.T) { //nolint:maintidx
},
expectedResponse: api.SignInProviderCallbackGet302Response{
Headers: api.SignInProviderCallbackGet302ResponseHeaders{
Location: `^http://localhost:3000\?refreshToken=.*$`,
Location: `^http://localhost:3000\?refreshToken=.*$`,
SetCookie: "fakeProviderTokens=%7B%22accessToken%22%3A%22valid-accesstoken-1%22%2C%22refreshToken%22%3A%22valid-refreshtoken-1%22%7D; Path=/; Max-Age=604800; HttpOnly; Secure; SameSite=Strict", //nolint:lll
},
},
expectedJWT: nil,
@@ -205,7 +206,8 @@ func TestSignInProviderCallback(t *testing.T) { //nolint:maintidx
},
expectedResponse: api.SignInProviderCallbackGet302Response{
Headers: api.SignInProviderCallbackGet302ResponseHeaders{
Location: `^http://localhost:3000/redirect/me/here\?refreshToken=.*$`,
Location: `^http://localhost:3000/redirect/me/here\?refreshToken=.*$`,
SetCookie: "fakeProviderTokens=%7B%22accessToken%22%3A%22valid-accesstoken-1%22%2C%22refreshToken%22%3A%22valid-refreshtoken-1%22%7D; Path=/; Max-Age=604800; HttpOnly; Secure; SameSite=Strict", //nolint:lll
},
},
expectedJWT: nil,
@@ -443,7 +445,8 @@ func TestSignInProviderCallback(t *testing.T) { //nolint:maintidx
},
expectedResponse: api.SignInProviderCallbackGet302Response{
Headers: api.SignInProviderCallbackGet302ResponseHeaders{
Location: `^http://localhost:3000\?refreshToken=.*$`,
Location: `^http://localhost:3000\?refreshToken=.*$`,
SetCookie: "fakeProviderTokens=%7B%22accessToken%22%3A%22valid-accesstoken-1%22%2C%22refreshToken%22%3A%22valid-refreshtoken-1%22%7D; Path=/; Max-Age=604800; HttpOnly; Secure; SameSite=Strict", //nolint:lll
},
},
expectedJWT: nil,
@@ -555,7 +558,8 @@ func TestSignInProviderCallback(t *testing.T) { //nolint:maintidx
},
expectedResponse: api.SignInProviderCallbackGet302Response{
Headers: api.SignInProviderCallbackGet302ResponseHeaders{
Location: `^http://localhost:3000\?refreshToken=.*$`,
Location: `^http://localhost:3000\?refreshToken=.*$`,
SetCookie: "fakeProviderTokens=%7B%22accessToken%22%3A%22valid-accesstoken-1%22%2C%22refreshToken%22%3A%22valid-refreshtoken-1%22%7D; Path=/; Max-Age=604800; HttpOnly; Secure; SameSite=Strict", //nolint:lll
},
},
expectedJWT: nil,
@@ -725,7 +729,7 @@ func TestSignInProviderCallback(t *testing.T) { //nolint:maintidx
Provider: "fake",
},
expectedResponse: controller.ErrorRedirectResponse{
Headers: api.SignInProviderCallbackGet302ResponseHeaders{
Headers: struct{ Location string }{
Location: `http://localhost:3000?error=oauth-provider-error&errorDescription=Provider+returned+an+error&provider_error=error-coming-from-provider&provider_error_description=This+is+an+error+coming+from+the+provider&provider_error_url=https%3A%2F%2Fexample.com%2Ferror`, //nolint:lll
},
},
@@ -810,7 +814,8 @@ func TestSignInProviderCallback(t *testing.T) { //nolint:maintidx
},
expectedResponse: api.SignInProviderCallbackGet302Response{
Headers: api.SignInProviderCallbackGet302ResponseHeaders{
Location: `^http://localhost:3000/connect-success$`,
Location: `^http://localhost:3000/connect-success$`,
SetCookie: "fakeProviderTokens=%7B%22accessToken%22%3A%22valid-accesstoken-1%22%2C%22refreshToken%22%3A%22valid-refreshtoken-1%22%7D; Path=/; Max-Age=604800; HttpOnly; Secure; SameSite=Strict", //nolint:lll
},
},
expectedJWT: nil,
@@ -1073,7 +1078,8 @@ func TestSignInProviderCallback(t *testing.T) { //nolint:maintidx
},
expectedResponse: api.SignInProviderCallbackGet302Response{
Headers: api.SignInProviderCallbackGet302ResponseHeaders{
Location: `^http://localhost:3000\?refreshToken=.*$`,
Location: `^http://localhost:3000\?refreshToken=.*$`,
SetCookie: "fakeProviderTokens=%7B%22accessToken%22%3A%22valid-accesstoken-empty-email%22%2C%22refreshToken%22%3A%22valid-refreshtoken-empty-email%22%7D; Path=/; Max-Age=604800; HttpOnly; Secure; SameSite=Strict", //nolint:lll
},
},
expectedJWT: nil,

View File

@@ -20,7 +20,7 @@ func ptr[T any](v T) *T {
return &v
}
func deptr[T any](v *T) T { //nolint:ireturn
func deptr[T any](v *T) T { //nolint:ireturn,nolintlint
if v == nil {
var zero T
return zero

View File

@@ -43,13 +43,15 @@ func (f *FakeProvider) Exchange(
switch code {
case "valid-code-1":
return &oauth2.Token{ //nolint:exhaustruct
AccessToken: "valid-accesstoken-1",
TokenType: "Bearer",
AccessToken: "valid-accesstoken-1",
RefreshToken: "valid-refreshtoken-1",
TokenType: "Bearer",
}, nil
case "valid-code-empty-email":
return &oauth2.Token{ //nolint:exhaustruct
AccessToken: "valid-accesstoken-empty-email",
TokenType: "Bearer",
AccessToken: "valid-accesstoken-empty-email",
RefreshToken: "valid-refreshtoken-empty-email",
TokenType: "Bearer",
}, nil
default:
return nil, errors.New("invalid code") //nolint:err113

View File

@@ -2,6 +2,14 @@
All notable changes to this project will be documented in this file.
## [storage@0.8.2] - 2025-10-14
### ⚙️ Miscellaneous Tasks
- *(auth)* Change some references to deprecated hasura-auth (#3584)
- *(docs)* Udpated README.md and CONTRIBUTING.md (#3587)
- *(storage)* Migrate to urfave and slog libraries (#3606)
## [storage@0.8.1] - 2025-09-30
### 🐛 Bug Fixes