Files
coder/coderd/oauth2provider/tokens.go
T
Bobby Ho 663f41ffa9 feat: derive OAuth2 client type from token_endpoint_auth_method (#28043)
Adds an OAuth2 client type (public vs confidential, RFC 7591 §2) derived
from the requested auth method instead of hardcoded confidential. The
type is stored and guarded here, but no endpoint enforces on it yet;
public behavior at the token endpoint follows in the next PR in the
stack.

- Client type is derived once and reused by both registration and
redirect URI validation, so they can't disagree
- IsPublic() fails closed: an unrecognized or missing value reads as
confidential
- RFC 7592 update (PUT) now rejects moving a client between public and
confidential (400) instead of silently flipping it when the auth method
is omitted
- Discovery still doesn't advertise "none"; follows once the token
endpoint honors it

### Behavior by client shape

`client_type` is derived from `token_endpoint_auth_method` at POST and
pinned at PUT. RFC 7592 GET/PUT authenticate with the registration
access token, not the client secret, so neither endpoint reads a secret.

| Registered with | Stored `client_type` / method | GET reports | PUT
that flips the method |

|------------------------------------|---------------------------------------|-----------------------|-----------------------------------------|
| omitted, or `client_secret_basic` | `confidential` /
`client_secret_basic` | `client_secret_basic` | `none` → 400
`invalid_client_metadata` |
| `none` (new) | `public` / `none` | `none` | `client_secret_*` → 400
`invalid_client_metadata` |
| `none` (before this PR) | `confidential` / `none` | `none` | either →
200, type stays `confidential` |

- PUT still replaces every other RFC 7591 field. `client_type` is the
only pinned one; the method may move within a type
(`client_secret_basic` ↔ `client_secret_post`).
- Row 3 is the only shape where the two columns disagree. The guard
fires only on a method change that crosses the type line, so those
clients keep managing themselves instead of being locked out of their
own configuration endpoint.
- The token endpoint does not consult `client_type` yet, so every client
still authenticates with a secret and registration still issues one.

Split out of #27873, second in the stack (on top of #28041).
Refs
https://linear.app/codercom/issue/ENG-3029/oauth2-support-public-client
2026-08-18 21:12:44 -07:00

609 lines
23 KiB
Go

package oauth2provider
import (
"context"
"database/sql"
"errors"
"fmt"
"net/http"
"net/url"
"slices"
"time"
"github.com/google/uuid"
"golang.org/x/xerrors"
"cdr.dev/slog/v3"
"github.com/coder/coder/v2/coderd/apikey"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/dbauthz"
"github.com/coder/coder/v2/coderd/database/dbtime"
"github.com/coder/coder/v2/coderd/httpapi"
"github.com/coder/coder/v2/coderd/httpmw"
"github.com/coder/coder/v2/coderd/httpmw/loggermw"
"github.com/coder/coder/v2/coderd/rbac"
"github.com/coder/coder/v2/codersdk"
)
var (
// errBadSecret means the user provided a bad secret.
errBadSecret = xerrors.New("Invalid client secret")
// errBadCode means the user provided a bad code.
errBadCode = xerrors.New("Invalid code")
// errBadToken means the user provided a bad token.
errBadToken = xerrors.New("Invalid token")
// errInvalidPKCE means the PKCE verification failed.
errInvalidPKCE = xerrors.New("invalid code_verifier")
// errInvalidResource means the resource parameter validation failed.
errInvalidResource = xerrors.New("invalid resource parameter")
// errConflictingClientAuth means the client provided credentials in both the
// request body and HTTP Basic, but they did not match.
errConflictingClientAuth = xerrors.New("conflicting client authentication")
)
func extractTokenRequest(r *http.Request, callbackURL *url.URL) (codersdk.OAuth2TokenRequest, []codersdk.ValidationError, error) {
p := httpapi.NewQueryParamParser()
err := r.ParseForm()
if err != nil {
return codersdk.OAuth2TokenRequest{}, nil, xerrors.Errorf("parse form: %w", err)
}
vals := r.Form
p.RequiredNotEmpty("grant_type")
grantType := httpapi.ParseCustom(p, vals, "", "grant_type", httpapi.ParseEnum[codersdk.OAuth2ProviderGrantType])
// Grant-type specific validation - must be called before parsing values.
switch grantType {
case codersdk.OAuth2ProviderGrantTypeRefreshToken:
p.RequiredNotEmpty("refresh_token")
case codersdk.OAuth2ProviderGrantTypeAuthorizationCode:
p.RequiredNotEmpty("code")
}
req := codersdk.OAuth2TokenRequest{
GrantType: grantType,
ClientID: p.String(vals, "", "client_id"),
ClientSecret: p.String(vals, "", "client_secret"),
Code: p.String(vals, "", "code"),
RedirectURI: p.String(vals, "", "redirect_uri"),
RefreshToken: p.String(vals, "", "refresh_token"),
CodeVerifier: p.String(vals, "", "code_verifier"),
Resource: p.String(vals, "", "resource"),
Scope: p.String(vals, "", "scope"),
}
// RFC 6749 §2.3.1: confidential clients may authenticate via HTTP Basic.
if user, pass, ok := r.BasicAuth(); ok && user != "" {
if req.ClientID != "" && req.ClientID != user {
return codersdk.OAuth2TokenRequest{}, nil, errConflictingClientAuth
}
if req.ClientSecret != "" && req.ClientSecret != pass {
return codersdk.OAuth2TokenRequest{}, nil, errConflictingClientAuth
}
req.ClientID = user
req.ClientSecret = pass
}
// Grant-specific required checks that can be satisfied via HTTP Basic.
if req.GrantType == codersdk.OAuth2ProviderGrantTypeAuthorizationCode {
if req.ClientID == "" {
p.Errors = append(p.Errors, codersdk.ValidationError{
Field: "client_id",
Detail: "Parameter \"client_id\" is required and cannot be empty",
})
}
if req.ClientSecret == "" {
p.Errors = append(p.Errors, codersdk.ValidationError{
Field: "client_secret",
Detail: "Parameter \"client_secret\" is required and cannot be empty",
})
}
// A code_verifier outside RFC 7636 §4.1's bounds is a syntax error
// (RFC 6749 §5.2), distinct from a well-formed verifier that fails the
// PKCE hash comparison in authorizationCodeGrant, which RFC 7636 §4.6
// maps to invalid_grant instead. Checking it here, alongside the other
// syntax validation, keeps the two failure modes distinguishable.
if !ValidPKCEFormat(req.CodeVerifier) {
p.Errors = append(p.Errors, codersdk.ValidationError{
Field: "code_verifier",
Detail: "must be 43 to 128 characters from the unreserved character set [A-Za-z0-9-._~] (RFC 7636 §4.1)",
})
}
}
// Validate redirect URI - errors are added to p.Errors.
_ = p.RedirectURL(vals, callbackURL, "redirect_uri")
// Validate resource parameter syntax (RFC 8707): must be absolute URI without fragment.
if err := validateResourceParameter(req.Resource); err != nil {
p.Errors = append(p.Errors, codersdk.ValidationError{
Field: "resource",
Detail: "must be an absolute URI without fragment",
})
}
p.ErrorExcessParams(vals)
if len(p.Errors) > 0 {
return codersdk.OAuth2TokenRequest{}, p.Errors, xerrors.Errorf("invalid query params: %w", p.Errors)
}
return req, nil, nil
}
// Tokens
// Uses Sessions.DefaultDuration for access token (API key) TTL and
// Sessions.RefreshDefaultDuration for refresh token TTL.
func Tokens(db database.Store, lifetimes codersdk.SessionLifetime) http.HandlerFunc {
return func(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
app := httpmw.OAuth2ProviderApp(r)
callbackURL, err := url.Parse(app.CallbackURL)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to validate form values.",
Detail: err.Error(),
})
return
}
req, validationErrs, err := extractTokenRequest(r, callbackURL)
if err != nil {
if errors.Is(err, errConflictingClientAuth) {
httpapi.WriteOAuth2Error(ctx, rw, http.StatusBadRequest, codersdk.OAuth2ErrorCodeInvalidRequest, "Conflicting client credentials between Authorization header and request body")
return
}
// Check for specific validation errors in priority order
if slices.ContainsFunc(validationErrs, func(validationError codersdk.ValidationError) bool {
return validationError.Field == "grant_type"
}) {
httpapi.WriteOAuth2Error(ctx, rw, http.StatusBadRequest, codersdk.OAuth2ErrorCodeUnsupportedGrantType, "The grant type is missing or unsupported")
return
}
// Check for missing required parameters for authorization_code grant
for _, field := range []string{"code", "client_id", "client_secret"} {
if slices.ContainsFunc(validationErrs, func(validationError codersdk.ValidationError) bool {
return validationError.Field == field
}) {
httpapi.WriteOAuth2Error(ctx, rw, http.StatusBadRequest, codersdk.OAuth2ErrorCodeInvalidRequest, fmt.Sprintf("Missing required parameter: %s", field))
return
}
}
// A malformed code_verifier gets its own message so a client that
// sent a well-formed but wrong verifier (rejected later, as
// invalid_grant, by the PKCE hash comparison) can tell the two
// failures apart instead of retrying the same bad verifier forever.
if slices.ContainsFunc(validationErrs, func(validationError codersdk.ValidationError) bool {
return validationError.Field == "code_verifier"
}) {
httpapi.WriteOAuth2Error(ctx, rw, http.StatusBadRequest, codersdk.OAuth2ErrorCodeInvalidRequest, "The code_verifier parameter must be 43 to 128 characters from the unreserved character set [A-Za-z0-9-._~] (RFC 7636 §4.1)")
return
}
// Generic invalid request for other validation errors
httpapi.WriteOAuth2Error(ctx, rw, http.StatusBadRequest, codersdk.OAuth2ErrorCodeInvalidRequest, "The request is missing required parameters or is otherwise malformed")
return
}
var token codersdk.OAuth2TokenResponse
//nolint:gocritic,revive // More cases will be added later.
switch req.GrantType {
// TODO: Client creds, device code.
case codersdk.OAuth2ProviderGrantTypeRefreshToken:
token, err = refreshTokenGrant(ctx, db, app, lifetimes, req)
case codersdk.OAuth2ProviderGrantTypeAuthorizationCode:
token, err = authorizationCodeGrant(ctx, db, app, lifetimes, req)
default:
// This should handle truly invalid grant types
httpapi.WriteOAuth2Error(ctx, rw, http.StatusBadRequest, codersdk.OAuth2ErrorCodeUnsupportedGrantType, fmt.Sprintf("The grant type %q is not supported", req.GrantType))
return
}
if errors.Is(err, errBadSecret) {
httpapi.WriteOAuth2Error(ctx, rw, http.StatusUnauthorized, codersdk.OAuth2ErrorCodeInvalidClient, "The client credentials are invalid")
return
}
if errors.Is(err, errBadCode) {
httpapi.WriteOAuth2Error(ctx, rw, http.StatusBadRequest, codersdk.OAuth2ErrorCodeInvalidGrant, "The authorization code is invalid or expired")
return
}
if errors.Is(err, errInvalidPKCE) {
httpapi.WriteOAuth2Error(ctx, rw, http.StatusBadRequest, codersdk.OAuth2ErrorCodeInvalidGrant, "The PKCE code verifier is invalid")
return
}
if errors.Is(err, errInvalidResource) {
httpapi.WriteOAuth2Error(ctx, rw, http.StatusBadRequest, codersdk.OAuth2ErrorCodeInvalidTarget, "The resource parameter is invalid")
return
}
if errors.Is(err, errBadToken) {
httpapi.WriteOAuth2Error(ctx, rw, http.StatusBadRequest, codersdk.OAuth2ErrorCodeInvalidGrant, "The refresh token is invalid or expired")
return
}
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to exchange token",
Detail: err.Error(),
})
return
}
// Some client libraries allow this to be "application/x-www-form-urlencoded". We can implement that upon
// request. The same libraries should also accept JSON. If implemented, choose based on "Accept" header.
httpapi.Write(ctx, rw, http.StatusOK, token)
}
}
// revokeOAuth2CodeOnPKCEFailure deletes a code that failed PKCE verification
// so it cannot be replayed with further code_verifier guesses (RFC 6749
// §10.5). Deletion failure does not change the response returned to the
// caller: surfacing it as a different error would let a caller distinguish
// "delete succeeded" from "delete failed," defeating the point of revoking
// the code in the first place. It is instead noted on the request's log line
// so operators can see it happened.
//
// A code that is already gone satisfies the goal, so sql.ErrNoRows is not a
// failure worth logging. It surfaces because the authorization check reads
// the code before deleting it, and that read reports a missing row when a
// concurrent attempt already revoked the code or it was reaped after expiry.
//
// The delete runs on a context detached from the request. The request context
// is canceled when the client disconnects, so a caller that fails PKCE and
// then drops the connection would otherwise leave its own code redeemable for
// the rest of its lifetime, which is the replay this function prevents.
func revokeOAuth2CodeOnPKCEFailure(ctx context.Context, db database.Store, codeID uuid.UUID) {
revokeCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second)
defer cancel()
//nolint:gocritic // OAuth2 system context, no authenticated user during token exchange
if err := db.DeleteOAuth2ProviderAppCodeByID(dbauthz.AsSystemOAuth2(revokeCtx), codeID); err != nil && !errors.Is(err, sql.ErrNoRows) {
if rlogger := loggermw.RequestLoggerFromContext(ctx); rlogger != nil {
rlogger.WithFields(slog.F("oauth2_pkce_failure_code_revoke_error", err.Error()))
}
}
}
func authorizationCodeGrant(ctx context.Context, db database.Store, app database.OAuth2ProviderApp, lifetimes codersdk.SessionLifetime, req codersdk.OAuth2TokenRequest) (codersdk.OAuth2TokenResponse, error) {
// Validate the client secret.
secret, err := ParseFormattedSecret(req.ClientSecret)
if err != nil {
return codersdk.OAuth2TokenResponse{}, errBadSecret
}
//nolint:gocritic // OAuth2 system context — users cannot read secrets
dbSecret, err := db.GetOAuth2ProviderAppSecretByPrefix(dbauthz.AsSystemOAuth2(ctx), []byte(secret.Prefix))
if errors.Is(err, sql.ErrNoRows) {
return codersdk.OAuth2TokenResponse{}, errBadSecret
}
if err != nil {
return codersdk.OAuth2TokenResponse{}, err
}
equalSecret := apikey.ValidateHash(dbSecret.HashedSecret, secret.Secret)
if !equalSecret {
return codersdk.OAuth2TokenResponse{}, errBadSecret
}
// The secret must belong to the app identified by the request's
// client_id, which is otherwise unauthenticated at this point (it is
// parsed straight from the request with no verification). Without this
// check, a valid secret for one app could issue a token attributed to a
// different app.
if dbSecret.AppID != app.ID {
return codersdk.OAuth2TokenResponse{}, errBadSecret
}
// Validate the authorization code.
code, err := ParseFormattedSecret(req.Code)
if err != nil {
return codersdk.OAuth2TokenResponse{}, errBadCode
}
//nolint:gocritic // OAuth2 system context — no authenticated user during token exchange
dbCode, err := db.GetOAuth2ProviderAppCodeByPrefix(dbauthz.AsSystemOAuth2(ctx), []byte(code.Prefix))
if errors.Is(err, sql.ErrNoRows) {
return codersdk.OAuth2TokenResponse{}, errBadCode
}
if err != nil {
return codersdk.OAuth2TokenResponse{}, err
}
equalCode := apikey.ValidateHash(dbCode.HashedSecret, code.Secret)
if !equalCode {
return codersdk.OAuth2TokenResponse{}, errBadCode
}
// The code must belong to the app identified by the request's
// client_id, for the same reason as the secret check above.
if dbCode.AppID != app.ID {
return codersdk.OAuth2TokenResponse{}, errBadCode
}
// Ensure the code has not expired.
if dbCode.ExpiresAt.Before(dbtime.Now()) {
return codersdk.OAuth2TokenResponse{}, errBadCode
}
// Verify redirect_uri matches the one used during authorization
// (RFC 6749 §4.1.3).
if dbCode.RedirectUri.Valid && dbCode.RedirectUri.String != "" {
if req.RedirectURI != dbCode.RedirectUri.String {
return codersdk.OAuth2TokenResponse{}, errBadCode
}
}
// PKCE is mandatory for all authorization code flows (OAuth 2.1). Verify
// the code verifier against the stored challenge. extractTokenRequest
// already rejected a malformed verifier as invalid_request, so
// req.CodeVerifier is guaranteed to meet RFC 7636 §4.1's bounds here; a
// mismatch below is a wrong-but-well-formed verifier, RFC 7636 §4.6's
// invalid_grant case.
//
// RFC 6749 §10.5 requires codes to be single-use. A code that survives a
// failed PKCE check would otherwise let a leaked code (the exact threat
// PKCE defends against) be replayed with different code_verifier guesses
// for the rest of its lifetime, unthrottled.
if !dbCode.CodeChallenge.Valid || dbCode.CodeChallenge.String == "" {
// Code was issued without a challenge, which should not happen
// with authorize endpoint enforcement, but defend in depth.
revokeOAuth2CodeOnPKCEFailure(ctx, db, dbCode.ID)
return codersdk.OAuth2TokenResponse{}, errInvalidPKCE
}
if !VerifyPKCE(dbCode.CodeChallenge.String, req.CodeVerifier) {
revokeOAuth2CodeOnPKCEFailure(ctx, db, dbCode.ID)
return codersdk.OAuth2TokenResponse{}, errInvalidPKCE
}
// Verify resource parameter consistency (RFC 8707)
if dbCode.ResourceUri.Valid && dbCode.ResourceUri.String != "" {
// Resource was specified during authorization - it must match in token request
if req.Resource == "" {
return codersdk.OAuth2TokenResponse{}, errInvalidResource
}
if req.Resource != dbCode.ResourceUri.String {
return codersdk.OAuth2TokenResponse{}, errInvalidResource
}
} else if req.Resource != "" {
// Resource was not specified during authorization but is now provided
return codersdk.OAuth2TokenResponse{}, errInvalidResource
}
// Generate a refresh token.
refreshToken, err := GenerateSecret()
if err != nil {
return codersdk.OAuth2TokenResponse{}, err
}
// Generate the API key we will swap for the code.
// TODO: We are ignoring scopes for now.
tokenName := fmt.Sprintf("%s_%s_oauth_session_token", dbCode.UserID, app.ID)
key, sessionToken, err := apikey.Generate(apikey.CreateParams{
UserID: dbCode.UserID,
LoginType: database.LoginTypeOAuth2ProviderApp,
DefaultLifetime: lifetimes.DefaultDuration.Value(),
// For now, we allow only one token per app and user at a time.
TokenName: tokenName,
})
if err != nil {
return codersdk.OAuth2TokenResponse{}, err
}
// Grab the user roles so we can perform the exchange as the user.
actor, _, err := httpmw.UserRBACSubject(ctx, db, dbCode.UserID, rbac.ScopeAll)
if err != nil {
return codersdk.OAuth2TokenResponse{}, xerrors.Errorf("fetch user actor: %w", err)
}
// Do the actual token exchange in the database.
// Determine refresh token expiry independently from the access token.
refreshLifetime := lifetimes.RefreshDefaultDuration.Value()
if refreshLifetime == 0 {
refreshLifetime = lifetimes.DefaultDuration.Value()
}
refreshExpiresAt := dbtime.Now().Add(refreshLifetime)
err = db.InTx(func(tx database.Store) error {
ctx := dbauthz.As(ctx, actor)
err = tx.DeleteOAuth2ProviderAppCodeByID(ctx, dbCode.ID)
if err != nil {
return xerrors.Errorf("delete oauth2 app code: %w", err)
}
// Delete the previous key, if any.
prevKey, err := tx.GetAPIKeyByName(ctx, database.GetAPIKeyByNameParams{
UserID: dbCode.UserID,
TokenName: tokenName,
})
if err == nil {
err = tx.DeleteAPIKeyByID(ctx, prevKey.ID)
}
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return xerrors.Errorf("delete api key by name: %w", err)
}
newKey, err := tx.InsertAPIKey(ctx, key)
if err != nil {
return xerrors.Errorf("insert oauth2 access token: %w", err)
}
_, err = tx.InsertOAuth2ProviderAppToken(ctx, database.InsertOAuth2ProviderAppTokenParams{
ID: uuid.New(),
CreatedAt: dbtime.Now(),
ExpiresAt: refreshExpiresAt,
HashPrefix: []byte(refreshToken.Prefix),
RefreshHash: refreshToken.Hashed,
AppID: dbCode.AppID,
AppSecretID: uuid.NullUUID{UUID: dbSecret.ID, Valid: true},
APIKeyID: newKey.ID,
UserID: dbCode.UserID,
Audience: dbCode.ResourceUri,
Scope: dbCode.Scope,
})
if err != nil {
return xerrors.Errorf("insert oauth2 refresh token: %w", err)
}
return nil
}, nil)
if err != nil {
return codersdk.OAuth2TokenResponse{}, err
}
return codersdk.OAuth2TokenResponse{
AccessToken: sessionToken,
TokenType: codersdk.OAuth2TokenTypeBearer,
RefreshToken: refreshToken.Formatted,
ExpiresIn: int64(time.Until(key.ExpiresAt).Seconds()),
Expiry: &key.ExpiresAt,
}, nil
}
func refreshTokenGrant(ctx context.Context, db database.Store, app database.OAuth2ProviderApp, lifetimes codersdk.SessionLifetime, req codersdk.OAuth2TokenRequest) (codersdk.OAuth2TokenResponse, error) {
// Validate the token.
token, err := ParseFormattedSecret(req.RefreshToken)
if err != nil {
return codersdk.OAuth2TokenResponse{}, errBadToken
}
//nolint:gocritic // OAuth2 system context — no authenticated user during refresh
dbToken, err := db.GetOAuth2ProviderAppTokenByPrefix(dbauthz.AsSystemOAuth2(ctx), []byte(token.Prefix))
if errors.Is(err, sql.ErrNoRows) {
return codersdk.OAuth2TokenResponse{}, errBadToken
}
if err != nil {
return codersdk.OAuth2TokenResponse{}, err
}
equal := apikey.ValidateHash(dbToken.RefreshHash, token.Secret)
if !equal {
return codersdk.OAuth2TokenResponse{}, errBadToken
}
// The token must belong to the app identified by the request's
// client_id, which is otherwise unauthenticated at this point (it is
// parsed straight from the request with no verification). Without this
// check, a stolen refresh token could be refreshed under a different
// app's client_id, re-parenting the token's app_id and breaking the
// issuing app's ability to revoke it.
if dbToken.AppID != app.ID {
return codersdk.OAuth2TokenResponse{}, errBadToken
}
// Ensure the token has not expired.
if dbToken.ExpiresAt.Before(dbtime.Now()) {
return codersdk.OAuth2TokenResponse{}, errBadToken
}
// Verify resource parameter consistency for refresh tokens (RFC 8707)
if req.Resource != "" {
// If resource is provided in refresh request, it must match the original token's audience
if !dbToken.Audience.Valid || dbToken.Audience.String != req.Resource {
return codersdk.OAuth2TokenResponse{}, errInvalidResource
}
}
// Grab the user roles so we can perform the refresh as the user.
//nolint:gocritic // OAuth2 system context — need to read the previous API key
prevKey, err := db.GetAPIKeyByID(dbauthz.AsSystemOAuth2(ctx), dbToken.APIKeyID)
if err != nil {
return codersdk.OAuth2TokenResponse{}, err
}
actor, _, err := httpmw.UserRBACSubject(ctx, db, prevKey.UserID, rbac.ScopeAll)
if err != nil {
return codersdk.OAuth2TokenResponse{}, xerrors.Errorf("fetch user actor: %w", err)
}
// Generate a new refresh token.
refreshToken, err := GenerateSecret()
if err != nil {
return codersdk.OAuth2TokenResponse{}, err
}
// Generate the new API key.
// TODO: We are ignoring scopes for now.
tokenName := fmt.Sprintf("%s_%s_oauth_session_token", prevKey.UserID, app.ID)
key, sessionToken, err := apikey.Generate(apikey.CreateParams{
UserID: prevKey.UserID,
LoginType: database.LoginTypeOAuth2ProviderApp,
DefaultLifetime: lifetimes.DefaultDuration.Value(),
// For now, we allow only one token per app and user at a time.
TokenName: tokenName,
})
if err != nil {
return codersdk.OAuth2TokenResponse{}, err
}
// Replace the token.
// Determine refresh token expiry independently from the access token.
refreshLifetime := lifetimes.RefreshDefaultDuration.Value()
if refreshLifetime == 0 {
refreshLifetime = lifetimes.DefaultDuration.Value()
}
refreshExpiresAt := dbtime.Now().Add(refreshLifetime)
err = db.InTx(func(tx database.Store) error {
ctx := dbauthz.As(ctx, actor)
err = tx.DeleteAPIKeyByID(ctx, prevKey.ID) // This cascades to the token.
if err != nil {
return xerrors.Errorf("delete oauth2 app token: %w", err)
}
newKey, err := tx.InsertAPIKey(ctx, key)
if err != nil {
return xerrors.Errorf("insert oauth2 access token: %w", err)
}
_, err = tx.InsertOAuth2ProviderAppToken(ctx, database.InsertOAuth2ProviderAppTokenParams{
ID: uuid.New(),
CreatedAt: dbtime.Now(),
ExpiresAt: refreshExpiresAt,
HashPrefix: []byte(refreshToken.Prefix),
RefreshHash: refreshToken.Hashed,
AppID: dbToken.AppID,
AppSecretID: dbToken.AppSecretID,
APIKeyID: newKey.ID,
UserID: dbToken.UserID,
Audience: dbToken.Audience,
// RFC 6749 §6: a refresh with no scope parameter is granted the
// originally granted scope. Later phases narrow this against
// req.Scope; they never widen it.
Scope: dbToken.Scope,
})
if err != nil {
return xerrors.Errorf("insert oauth2 refresh token: %w", err)
}
return nil
}, nil)
if err != nil {
return codersdk.OAuth2TokenResponse{}, err
}
return codersdk.OAuth2TokenResponse{
AccessToken: sessionToken,
TokenType: codersdk.OAuth2TokenTypeBearer,
RefreshToken: refreshToken.Formatted,
ExpiresIn: int64(time.Until(key.ExpiresAt).Seconds()),
Expiry: &key.ExpiresAt,
}, nil
}
// validateResourceParameter validates that a resource parameter conforms to RFC 8707:
// must be an absolute URI without fragment component.
func validateResourceParameter(resource string) error {
if resource == "" {
return nil // Resource parameter is optional
}
u, err := url.Parse(resource)
if err != nil {
return xerrors.Errorf("invalid URI syntax: %w", err)
}
if u.Scheme == "" {
return xerrors.New("must be an absolute URI with scheme")
}
if u.Fragment != "" {
return xerrors.New("must not contain fragment component")
}
return nil
}