mirror of
https://github.com/steveiliop56/tinyauth.git
synced 2026-02-24 09:52:00 +00:00
feat: refresh token grant type support
This commit is contained in:
@@ -10,9 +10,11 @@ CREATE TABLE IF NOT EXISTS "oidc_codes" (
|
||||
CREATE TABLE IF NOT EXISTS "oidc_tokens" (
|
||||
"sub" TEXT NOT NULL UNIQUE,
|
||||
"access_token_hash" TEXT NOT NULL PRIMARY KEY UNIQUE,
|
||||
"refresh_token_hash" TEXT NOT NULL,
|
||||
"scope" TEXT NOT NULL,
|
||||
"client_id" TEXT NOT NULL,
|
||||
"expires_at" INTEGER NOT NULL
|
||||
"token_expires_at" INTEGER NOT NULL,
|
||||
"refresh_token_expires_at" INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "oidc_userinfo" (
|
||||
|
||||
@@ -29,9 +29,12 @@ type AuthorizeCallback struct {
|
||||
}
|
||||
|
||||
type TokenRequest struct {
|
||||
GrantType string `form:"grant_type" binding:"required"`
|
||||
Code string `form:"code" binding:"required"`
|
||||
RedirectURI string `form:"redirect_uri" binding:"required"`
|
||||
GrantType string `form:"grant_type" binding:"required"`
|
||||
Code string `form:"code"`
|
||||
RedirectURI string `form:"redirect_uri"`
|
||||
RefreshToken string `form:"refresh_token"`
|
||||
ClientID string `form:"client_id"`
|
||||
ClientSecret string `form:"client_secret"`
|
||||
}
|
||||
|
||||
type CallbackError struct {
|
||||
@@ -176,34 +179,6 @@ func (controller *OIDCController) Authorize(c *gin.Context) {
|
||||
}
|
||||
|
||||
func (controller *OIDCController) Token(c *gin.Context) {
|
||||
rclientId, rclientSecret, ok := c.Request.BasicAuth()
|
||||
|
||||
if !ok {
|
||||
tlog.App.Error().Msg("Missing authorization header")
|
||||
c.JSON(400, gin.H{
|
||||
"error": "invalid_request",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
client, ok := controller.oidc.GetClient(rclientId)
|
||||
|
||||
if !ok {
|
||||
tlog.App.Warn().Str("client_id", rclientId).Msg("Client not found")
|
||||
c.JSON(400, gin.H{
|
||||
"error": "access_denied",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if client.ClientSecret != rclientSecret {
|
||||
tlog.App.Warn().Str("client_id", rclientId).Msg("Invalid client secret")
|
||||
c.JSON(400, gin.H{
|
||||
"error": "access_denied",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
var req TokenRequest
|
||||
|
||||
err := c.Bind(&req)
|
||||
@@ -224,58 +199,131 @@ func (controller *OIDCController) Token(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
entry, err := controller.oidc.GetCodeEntry(c, controller.oidc.Hash(req.Code))
|
||||
if err != nil {
|
||||
if errors.Is(err, service.ErrCodeExpired) {
|
||||
tlog.App.Warn().Str("code", req.Code).Msg("Code expired")
|
||||
var tokenResponse service.TokenResponse
|
||||
|
||||
switch req.GrantType {
|
||||
case "authorization_code":
|
||||
rclientId, rclientSecret, ok := c.Request.BasicAuth()
|
||||
|
||||
if !ok {
|
||||
tlog.App.Error().Msg("Missing authorization header")
|
||||
c.JSON(400, gin.H{
|
||||
"error": "invalid_request",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
client, ok := controller.oidc.GetClient(rclientId)
|
||||
|
||||
if !ok {
|
||||
tlog.App.Warn().Str("client_id", rclientId).Msg("Client not found")
|
||||
c.JSON(400, gin.H{
|
||||
"error": "access_denied",
|
||||
})
|
||||
return
|
||||
}
|
||||
if errors.Is(err, service.ErrCodeNotFound) {
|
||||
tlog.App.Warn().Str("code", req.Code).Msg("Code not found")
|
||||
|
||||
if client.ClientSecret != rclientSecret {
|
||||
tlog.App.Warn().Str("client_id", rclientId).Msg("Invalid client secret")
|
||||
c.JSON(400, gin.H{
|
||||
"error": "access_denied",
|
||||
})
|
||||
return
|
||||
}
|
||||
tlog.App.Warn().Err(err).Msg("Failed to get OIDC code entry")
|
||||
c.JSON(400, gin.H{
|
||||
"error": "server_error",
|
||||
})
|
||||
return
|
||||
|
||||
entry, err := controller.oidc.GetCodeEntry(c, controller.oidc.Hash(req.Code))
|
||||
if err != nil {
|
||||
if errors.Is(err, service.ErrCodeNotFound) {
|
||||
tlog.App.Warn().Str("code", req.Code).Msg("Code not found")
|
||||
c.JSON(400, gin.H{
|
||||
"error": "access_denied",
|
||||
})
|
||||
return
|
||||
}
|
||||
if errors.Is(err, service.ErrCodeExpired) {
|
||||
tlog.App.Warn().Str("code", req.Code).Msg("Code expired")
|
||||
c.JSON(400, gin.H{
|
||||
"error": "access_denied",
|
||||
})
|
||||
return
|
||||
}
|
||||
tlog.App.Warn().Err(err).Msg("Failed to get OIDC code entry")
|
||||
c.JSON(400, gin.H{
|
||||
"error": "server_error",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if entry.RedirectURI != req.RedirectURI {
|
||||
tlog.App.Warn().Str("redirect_uri", req.RedirectURI).Msg("Redirect URI mismatch")
|
||||
c.JSON(400, gin.H{
|
||||
"error": "invalid_request_uri",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
tokenRes, err := controller.oidc.GenerateAccessToken(c, client, entry.Sub, entry.Scope)
|
||||
|
||||
if err != nil {
|
||||
tlog.App.Error().Err(err).Msg("Failed to generate access token")
|
||||
c.JSON(400, gin.H{
|
||||
"error": "server_error",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
err = controller.oidc.DeleteCodeEntry(c, entry.CodeHash)
|
||||
|
||||
if err != nil {
|
||||
tlog.App.Error().Err(err).Msg("Failed to delete code in database")
|
||||
c.JSON(400, gin.H{
|
||||
"error": "server_error",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
tokenResponse = tokenRes
|
||||
case "refresh_token":
|
||||
client, ok := controller.oidc.GetClient(req.ClientID)
|
||||
|
||||
if !ok {
|
||||
tlog.App.Error().Msg("OIDC refresh token request with invalid client ID")
|
||||
c.JSON(400, gin.H{
|
||||
"error": "invalid_client",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if client.ClientSecret != req.ClientSecret {
|
||||
tlog.App.Error().Msg("OIDC refresh token request with invalid client secret")
|
||||
c.JSON(400, gin.H{
|
||||
"error": "invalid_client",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
tokenRes, err := controller.oidc.RefreshAccessToken(c, req.RefreshToken)
|
||||
|
||||
if err != nil {
|
||||
if errors.Is(err, service.ErrTokenExpired) {
|
||||
tlog.App.Error().Err(err).Msg("Failed to refresh access token")
|
||||
c.JSON(401, gin.H{
|
||||
"error": "access_denied",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
tlog.App.Error().Err(err).Msg("Failed to refresh access token")
|
||||
c.JSON(400, gin.H{
|
||||
"error": "server_error",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
tokenResponse = tokenRes
|
||||
}
|
||||
|
||||
if entry.RedirectURI != req.RedirectURI {
|
||||
tlog.App.Warn().Str("redirect_uri", req.RedirectURI).Msg("Redirect URI mismatch")
|
||||
c.JSON(400, gin.H{
|
||||
"error": "invalid_request_uri",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
accessToken, err := controller.oidc.GenerateAccessToken(c, client, entry.Sub, entry.Scope)
|
||||
|
||||
if err != nil {
|
||||
tlog.App.Error().Err(err).Msg("Failed to generate access token")
|
||||
c.JSON(400, gin.H{
|
||||
"error": "server_error",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
err = controller.oidc.DeleteCodeEntry(c, entry.CodeHash)
|
||||
|
||||
if err != nil {
|
||||
tlog.App.Error().Err(err).Msg("Failed to delete code in database")
|
||||
c.JSON(400, gin.H{
|
||||
"error": "server_error",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(200, accessToken)
|
||||
c.JSON(200, tokenResponse)
|
||||
}
|
||||
|
||||
func (controller *OIDCController) Userinfo(c *gin.Context) {
|
||||
@@ -305,7 +353,7 @@ func (controller *OIDCController) Userinfo(c *gin.Context) {
|
||||
if err == service.ErrTokenNotFound {
|
||||
tlog.App.Warn().Msg("OIDC userinfo accessed with invalid token")
|
||||
c.JSON(401, gin.H{
|
||||
"error": "invalid_request",
|
||||
"error": "access_denied",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -14,11 +14,13 @@ type OidcCode struct {
|
||||
}
|
||||
|
||||
type OidcToken struct {
|
||||
Sub string
|
||||
AccessTokenHash string
|
||||
Scope string
|
||||
ClientID string
|
||||
ExpiresAt int64
|
||||
Sub string
|
||||
AccessTokenHash string
|
||||
RefreshTokenHash string
|
||||
Scope string
|
||||
ClientID string
|
||||
TokenExpiresAt int64
|
||||
RefreshTokenExpiresAt int64
|
||||
}
|
||||
|
||||
type OidcUserinfo struct {
|
||||
|
||||
@@ -57,38 +57,46 @@ const createOidcToken = `-- name: CreateOidcToken :one
|
||||
INSERT INTO "oidc_tokens" (
|
||||
"sub",
|
||||
"access_token_hash",
|
||||
"refresh_token_hash",
|
||||
"scope",
|
||||
"client_id",
|
||||
"expires_at"
|
||||
"token_expires_at",
|
||||
"refresh_token_expires_at"
|
||||
) VALUES (
|
||||
?, ?, ?, ?, ?
|
||||
?, ?, ?, ?, ?, ?, ?
|
||||
)
|
||||
RETURNING sub, access_token_hash, scope, client_id, expires_at
|
||||
RETURNING sub, access_token_hash, refresh_token_hash, scope, client_id, token_expires_at, refresh_token_expires_at
|
||||
`
|
||||
|
||||
type CreateOidcTokenParams struct {
|
||||
Sub string
|
||||
AccessTokenHash string
|
||||
Scope string
|
||||
ClientID string
|
||||
ExpiresAt int64
|
||||
Sub string
|
||||
AccessTokenHash string
|
||||
RefreshTokenHash string
|
||||
Scope string
|
||||
ClientID string
|
||||
TokenExpiresAt int64
|
||||
RefreshTokenExpiresAt int64
|
||||
}
|
||||
|
||||
func (q *Queries) CreateOidcToken(ctx context.Context, arg CreateOidcTokenParams) (OidcToken, error) {
|
||||
row := q.db.QueryRowContext(ctx, createOidcToken,
|
||||
arg.Sub,
|
||||
arg.AccessTokenHash,
|
||||
arg.RefreshTokenHash,
|
||||
arg.Scope,
|
||||
arg.ClientID,
|
||||
arg.ExpiresAt,
|
||||
arg.TokenExpiresAt,
|
||||
arg.RefreshTokenExpiresAt,
|
||||
)
|
||||
var i OidcToken
|
||||
err := row.Scan(
|
||||
&i.Sub,
|
||||
&i.AccessTokenHash,
|
||||
&i.RefreshTokenHash,
|
||||
&i.Scope,
|
||||
&i.ClientID,
|
||||
&i.ExpiresAt,
|
||||
&i.TokenExpiresAt,
|
||||
&i.RefreshTokenExpiresAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -207,7 +215,7 @@ func (q *Queries) GetOidcCode(ctx context.Context, codeHash string) (OidcCode, e
|
||||
}
|
||||
|
||||
const getOidcToken = `-- name: GetOidcToken :one
|
||||
SELECT sub, access_token_hash, scope, client_id, expires_at FROM "oidc_tokens"
|
||||
SELECT sub, access_token_hash, refresh_token_hash, scope, client_id, token_expires_at, refresh_token_expires_at FROM "oidc_tokens"
|
||||
WHERE "access_token_hash" = ?
|
||||
`
|
||||
|
||||
@@ -217,9 +225,31 @@ func (q *Queries) GetOidcToken(ctx context.Context, accessTokenHash string) (Oid
|
||||
err := row.Scan(
|
||||
&i.Sub,
|
||||
&i.AccessTokenHash,
|
||||
&i.RefreshTokenHash,
|
||||
&i.Scope,
|
||||
&i.ClientID,
|
||||
&i.ExpiresAt,
|
||||
&i.TokenExpiresAt,
|
||||
&i.RefreshTokenExpiresAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getOidcTokenByRefreshToken = `-- name: GetOidcTokenByRefreshToken :one
|
||||
SELECT sub, access_token_hash, refresh_token_hash, scope, client_id, token_expires_at, refresh_token_expires_at FROM "oidc_tokens"
|
||||
WHERE "refresh_token_hash" = ?
|
||||
`
|
||||
|
||||
func (q *Queries) GetOidcTokenByRefreshToken(ctx context.Context, refreshTokenHash string) (OidcToken, error) {
|
||||
row := q.db.QueryRowContext(ctx, getOidcTokenByRefreshToken, refreshTokenHash)
|
||||
var i OidcToken
|
||||
err := row.Scan(
|
||||
&i.Sub,
|
||||
&i.AccessTokenHash,
|
||||
&i.RefreshTokenHash,
|
||||
&i.Scope,
|
||||
&i.ClientID,
|
||||
&i.TokenExpiresAt,
|
||||
&i.RefreshTokenExpiresAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -242,3 +272,42 @@ func (q *Queries) GetOidcUserInfo(ctx context.Context, sub string) (OidcUserinfo
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const updateOidcTokenByRefreshToken = `-- name: UpdateOidcTokenByRefreshToken :one
|
||||
UPDATE "oidc_tokens" SET
|
||||
"access_token_hash" = ?,
|
||||
"refresh_token_hash" = ?,
|
||||
"token_expires_at" = ?,
|
||||
"refresh_token_expires_at" = ?
|
||||
WHERE "refresh_token_hash" = ?
|
||||
RETURNING sub, access_token_hash, refresh_token_hash, scope, client_id, token_expires_at, refresh_token_expires_at
|
||||
`
|
||||
|
||||
type UpdateOidcTokenByRefreshTokenParams struct {
|
||||
AccessTokenHash string
|
||||
RefreshTokenHash string
|
||||
TokenExpiresAt int64
|
||||
RefreshTokenExpiresAt int64
|
||||
RefreshTokenHash_2 string
|
||||
}
|
||||
|
||||
func (q *Queries) UpdateOidcTokenByRefreshToken(ctx context.Context, arg UpdateOidcTokenByRefreshTokenParams) (OidcToken, error) {
|
||||
row := q.db.QueryRowContext(ctx, updateOidcTokenByRefreshToken,
|
||||
arg.AccessTokenHash,
|
||||
arg.RefreshTokenHash,
|
||||
arg.TokenExpiresAt,
|
||||
arg.RefreshTokenExpiresAt,
|
||||
arg.RefreshTokenHash_2,
|
||||
)
|
||||
var i OidcToken
|
||||
err := row.Scan(
|
||||
&i.Sub,
|
||||
&i.AccessTokenHash,
|
||||
&i.RefreshTokenHash,
|
||||
&i.Scope,
|
||||
&i.ClientID,
|
||||
&i.TokenExpiresAt,
|
||||
&i.RefreshTokenExpiresAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ import (
|
||||
var (
|
||||
SupportedScopes = []string{"openid", "profile", "email", "groups"}
|
||||
SupportedResponseTypes = []string{"code"}
|
||||
SupportedGrantTypes = []string{"authorization_code"}
|
||||
SupportedGrantTypes = []string{"authorization_code", "refresh_token"}
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -49,11 +49,12 @@ type UserinfoResponse struct {
|
||||
}
|
||||
|
||||
type TokenResponse struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
TokenType string `json:"token_type"`
|
||||
ExpiresIn int64 `json:"expires_in"`
|
||||
IDToken string `json:"id_token"`
|
||||
Scope string `json:"scope"`
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
TokenType string `json:"token_type"`
|
||||
ExpiresIn int64 `json:"expires_in"`
|
||||
IDToken string `json:"id_token"`
|
||||
Scope string `json:"scope"`
|
||||
}
|
||||
|
||||
type AuthorizeRequest struct {
|
||||
@@ -361,21 +362,81 @@ func (service *OIDCService) GenerateAccessToken(c *gin.Context, client config.OI
|
||||
}
|
||||
|
||||
accessToken := rand.Text()
|
||||
expiresAt := time.Now().Add(time.Duration(1) * time.Hour).Unix()
|
||||
refreshToken := rand.Text()
|
||||
|
||||
tokenExpiresAt := time.Now().Add(time.Duration(service.config.SessionExpiry) * time.Second).Unix()
|
||||
|
||||
// Refresh token lives double the time of an access token but can't be used to access userinfo
|
||||
refrshTokenExpiresAt := time.Now().Add(time.Duration(service.config.SessionExpiry*2) * time.Second).Unix()
|
||||
|
||||
tokenResponse := TokenResponse{
|
||||
AccessToken: accessToken,
|
||||
TokenType: "Bearer",
|
||||
ExpiresIn: int64(time.Hour.Seconds()),
|
||||
IDToken: idToken,
|
||||
Scope: strings.ReplaceAll(scope, ",", " "),
|
||||
AccessToken: accessToken,
|
||||
RefreshToken: refreshToken,
|
||||
TokenType: "Bearer",
|
||||
ExpiresIn: int64(service.config.SessionExpiry),
|
||||
IDToken: idToken,
|
||||
Scope: strings.ReplaceAll(scope, ",", " "),
|
||||
}
|
||||
|
||||
_, err = service.queries.CreateOidcToken(c, repository.CreateOidcTokenParams{
|
||||
Sub: sub,
|
||||
AccessTokenHash: service.Hash(accessToken),
|
||||
Scope: scope,
|
||||
ExpiresAt: expiresAt,
|
||||
Sub: sub,
|
||||
AccessTokenHash: service.Hash(accessToken),
|
||||
RefreshTokenHash: service.Hash(refreshToken),
|
||||
Scope: scope,
|
||||
TokenExpiresAt: tokenExpiresAt,
|
||||
RefreshTokenExpiresAt: refrshTokenExpiresAt,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return TokenResponse{}, err
|
||||
}
|
||||
|
||||
return tokenResponse, nil
|
||||
}
|
||||
|
||||
func (service *OIDCService) RefreshAccessToken(c *gin.Context, refreshToken string) (TokenResponse, error) {
|
||||
entry, err := service.queries.GetOidcTokenByRefreshToken(c, service.Hash(refreshToken))
|
||||
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return TokenResponse{}, ErrTokenNotFound
|
||||
}
|
||||
return TokenResponse{}, err
|
||||
}
|
||||
|
||||
if entry.RefreshTokenExpiresAt < time.Now().Unix() {
|
||||
return TokenResponse{}, ErrTokenExpired
|
||||
}
|
||||
|
||||
idToken, err := service.generateIDToken(config.OIDCClientConfig{
|
||||
ClientID: entry.ClientID,
|
||||
}, entry.Sub)
|
||||
|
||||
if err != nil {
|
||||
return TokenResponse{}, err
|
||||
}
|
||||
|
||||
accessToken := rand.Text()
|
||||
newRefreshToken := rand.Text()
|
||||
|
||||
tokenExpiresAt := time.Now().Add(time.Duration(service.config.SessionExpiry) * time.Second).Unix()
|
||||
refrshTokenExpiresAt := time.Now().Add(time.Duration(service.config.SessionExpiry*2) * time.Second).Unix()
|
||||
|
||||
tokenResponse := TokenResponse{
|
||||
AccessToken: accessToken,
|
||||
RefreshToken: refreshToken,
|
||||
TokenType: "Bearer",
|
||||
ExpiresIn: int64(service.config.SessionExpiry),
|
||||
IDToken: idToken,
|
||||
Scope: strings.ReplaceAll(entry.Scope, ",", " "),
|
||||
}
|
||||
|
||||
_, err = service.queries.UpdateOidcTokenByRefreshToken(c, repository.UpdateOidcTokenByRefreshTokenParams{
|
||||
AccessTokenHash: service.Hash(accessToken),
|
||||
RefreshTokenHash: service.Hash(newRefreshToken),
|
||||
TokenExpiresAt: tokenExpiresAt,
|
||||
RefreshTokenExpiresAt: refrshTokenExpiresAt,
|
||||
RefreshTokenHash_2: service.Hash(refreshToken), // that's the selector, it's not stored in the db
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
@@ -407,14 +468,17 @@ func (service *OIDCService) GetAccessToken(c *gin.Context, tokenHash string) (re
|
||||
return repository.OidcToken{}, err
|
||||
}
|
||||
|
||||
if entry.ExpiresAt < time.Now().Unix() {
|
||||
err := service.DeleteToken(c, tokenHash)
|
||||
if err != nil {
|
||||
return repository.OidcToken{}, err
|
||||
}
|
||||
err = service.DeleteUserinfo(c, entry.Sub)
|
||||
if err != nil {
|
||||
return repository.OidcToken{}, err
|
||||
if entry.TokenExpiresAt < time.Now().Unix() {
|
||||
// If refresh token is expired, delete the token and userinfo since there is no way for the client to access anything anymore
|
||||
if entry.RefreshTokenExpiresAt < time.Now().Unix() {
|
||||
err := service.DeleteToken(c, tokenHash)
|
||||
if err != nil {
|
||||
return repository.OidcToken{}, err
|
||||
}
|
||||
err = service.DeleteUserinfo(c, entry.Sub)
|
||||
if err != nil {
|
||||
return repository.OidcToken{}, err
|
||||
}
|
||||
}
|
||||
return repository.OidcToken{}, ErrTokenExpired
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user