Files
coder/coderd/oauth2provider/tokens.go
T
Bobby Ho 6348c83142 feat: register public clients without a secret (#28046)
Third in the stack, split out of #27873. This is where dynamic client
registration first produces a public client.

- A registration requesting token_endpoint_auth_method `none` issues no
secret and persists the client_type derived in #28043. The response
omits client_secret entirely, per RFC 7591 §3.2.1.
- Discovery still advertises only the two secret-based methods. `none`
is withheld until the token endpoint honors it, so a conforming client
is not told to attempt an exchange that would be rejected.
- The app and its secret are written in one transaction. They were two
independent inserts, so a failed second one left a committed app that
could never authenticate while still holding a registration access
token. Pre-existing, but a public client's missing secret row makes the
orphaned case unspottable by inspection.
- registration_client_uri uses url.JoinPath, fixing a trailing-slash
access URL producing //oauth2/clients/{id}.
- Docs state both limits above, so the page does not describe a flow
that returns 400 until #28047 lands.

A client registered here cannot obtain a token yet; #28047 adds that.
Dynamic client registration is off by default, so none of this is
user-visible until then.

Refs
https://linear.app/codercom/issue/ENG-3029/oauth2-support-public-client
2026-08-20 12:49:47 -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
}