feat: implement standardized OAuth2 endpoints and token revocation#18809
feat: implement standardized OAuth2 endpoints and token revocation#18809ThomasK33 wants to merge 2 commits into
Conversation
bc44107 to
ab73979
Compare
ab73979 to
b89c367
Compare
b89c367 to
b73b71d
Compare
514f744 to
386d77d
Compare
01f48f3 to
9393060
Compare
9393060 to
2c31819
Compare
aca2f6a to
35d7f5a
Compare
aslilac
left a comment
There was a problem hiding this comment.
I'm afraid this pr is large enough to be very difficult to review 😅 any ideas on how we could break it up?
I would call out the size even for something half this line count. I think the ideal diff size for getting good reviews is under 1k.
| const ( | ||
| // ANSI color codes | ||
| colorReset = "\033[0m" | ||
| colorRed = "\033[31m" | ||
| colorGreen = "\033[32m" | ||
| colorYellow = "\033[33m" | ||
| colorBlue = "\033[34m" | ||
| colorPurple = "\033[35m" | ||
| colorCyan = "\033[36m" | ||
| colorWhite = "\033[37m" | ||
| ) |
There was a problem hiding this comment.
I see other places in the codebase where we use github.com/muesli/termenv for this, which will do a better job of not flooding the output with noise when stdout isn't a tty. it's maybe not a big deal for something in scripts/, but we already pull in the dependency. might as well use it.
| } | ||
| _ = resp.Body.Close() | ||
|
|
||
| if errorCode, ok := result["error"].(string); ok { |
There was a problem hiding this comment.
this should probably use codersdk.AsError
| // NOTE: OAuth2 client registration validation tests have been migrated to | ||
| // oauth2provider/validation_test.go for better separation of concerns |
There was a problem hiding this comment.
feels weird to leave this comment in the middle of such a huge file. maybe move it to the top actually?
| COMMENT ON TABLE oauth2_provider_device_codes IS 'RFC 8628: OAuth2 Device Authorization Grant device codes'; | ||
| COMMENT ON COLUMN oauth2_provider_device_codes.device_code_hash IS 'Hashed device code for security'; | ||
| COMMENT ON COLUMN oauth2_provider_device_codes.device_code_prefix IS 'Device code prefix for lookup (first 8 chars)'; | ||
| COMMENT ON COLUMN oauth2_provider_device_codes.user_code IS 'Human-readable code shown to user (6-8 characters)'; | ||
| COMMENT ON COLUMN oauth2_provider_device_codes.verification_uri IS 'URI where user enters user_code'; | ||
| COMMENT ON COLUMN oauth2_provider_device_codes.verification_uri_complete IS 'Optional complete URI with user_code embedded'; | ||
| COMMENT ON COLUMN oauth2_provider_device_codes.polling_interval IS 'Minimum polling interval in seconds (RFC 8628)'; | ||
| COMMENT ON COLUMN oauth2_provider_device_codes.resource_uri IS 'RFC 8707 resource parameter for audience restriction'; | ||
| COMMENT ON COLUMN oauth2_provider_device_codes.status IS 'Current authorization status: pending (awaiting user action), authorized (user approved), or denied (user rejected)'; |
There was a problem hiding this comment.
I think we generally tend to be more conservative than this with SQL comments. some of these seem a bit overkill, like status which is an enum type and pretty self explanatory.
| client_id uuid NOT NULL REFERENCES oauth2_provider_apps(id) ON DELETE CASCADE, | ||
| user_id uuid REFERENCES users(id) ON DELETE CASCADE, -- NULL until authorized | ||
|
|
||
| -- Authorization state (using enum for better data integrity) |
There was a problem hiding this comment.
similarly here, I don't think this needs to be rationalized. enums are a great choice!
| @@ -0,0 +1,8 @@ | |||
| -- Remove OAuth2 Device Authorization Grant support (RFC 8628) | |||
|
|
|||
| -- Remove constraints added for data integrity | |||
There was a problem hiding this comment.
this is also kinda weird. I imagine some agent inserted this? saying "this was added for data integrity" as you destroy it is just tonally odd.
|
@ThomasK33 @Emyrk Sorry, somehow missed the first ping from two weeks ago. Where are we at on the PR? Do we just need another pass on the frontend? |
- Change /oauth2/tokens → /oauth2/token per RFC 6749 - Move token deletion to POST /oauth2/revoke per RFC 7009 - Update all endpoint URLs and documentation - Maintain backward compatibility in client libraries feat: implement OAuth2 Device Authorization Grant (RFC 8628) - Add device authorization endpoint /oauth2/device/authorize - Add device verification UI at /oauth2/device - Support device_code grant type in token endpoint - Add database table for device codes with expiration - Implement polling interval and user authorization flow - Add comprehensive test coverage for device flow Change-Id: I7a7eebeb23a4f28718ebed2994d01dc21b49315b Signed-off-by: Thomas Kosiewski <tk@coder.com>
35d7f5a to
8b4b6f0
Compare
Change-Id: Ic232851727e683ab3d8b7ce970c505588da2f827 Signed-off-by: Thomas Kosiewski <tk@coder.com>
8b4b6f0 to
81adc67
Compare
| type parsedAPIKey struct { | ||
| keyID string // The API key ID for database lookup | ||
| secret string // The secret part for verification | ||
| } | ||
|
|
||
| // parseAPIKeyToken parses an API key token following the encoder/decoder pattern | ||
| func parseAPIKeyToken(token string) (parsedAPIKey, error) { | ||
| parts := strings.SplitN(token, "-", 2) | ||
| if len(parts) != 2 { | ||
| return parsedAPIKey{}, xerrors.Errorf("incorrect number of parts: %d", len(parts)) | ||
| } | ||
| if parts[0] == "" || parts[1] == "" { | ||
| return parsedAPIKey{}, xerrors.New("empty key ID or secret") | ||
| } | ||
| return parsedAPIKey{ | ||
| keyID: parts[0], | ||
| secret: parts[1], | ||
| }, nil | ||
| } |
There was a problem hiding this comment.
| func revokeAPIKeyInTx(ctx context.Context, db database.Store, token string, appID uuid.UUID) error { | ||
| // Parse the API key using the structured decoder | ||
| parsedKey, err := parseAPIKeyToken(token) | ||
| if err != nil { | ||
| return ErrInvalidTokenFormat | ||
| } | ||
|
|
||
| // Get the API key | ||
| //nolint:gocritic // Using AsSystemOAuth2 for OAuth2 public token revocation endpoint | ||
| apiKey, err := db.GetAPIKeyByID(dbauthz.AsSystemOAuth2(ctx), parsedKey.keyID) | ||
| if err != nil { | ||
| if errors.Is(err, sql.ErrNoRows) { | ||
| // API key not found - return success per RFC 7009 (don't reveal token existence) | ||
| // Note: This covers both non-existent keys and invalid key ID formats | ||
| return nil | ||
| } | ||
| return xerrors.Errorf("get api key by id: %w", err) | ||
| } | ||
|
|
||
| // Verify the API key was created by OAuth2 | ||
| if apiKey.LoginType != database.LoginTypeOAuth2ProviderApp { | ||
| return xerrors.New("API key is not an OAuth2 token") | ||
| } | ||
|
|
||
| // Find the associated OAuth2 token to verify ownership | ||
| //nolint:gocritic // Using AsSystemOAuth2 for OAuth2 public token revocation endpoint | ||
| dbToken, err := db.GetOAuth2ProviderAppTokenByAPIKeyID(dbauthz.AsSystemOAuth2(ctx), apiKey.ID) | ||
| if err != nil { | ||
| if errors.Is(err, sql.ErrNoRows) { | ||
| // No associated OAuth2 token - return success per RFC 7009 | ||
| return nil | ||
| } | ||
| return xerrors.Errorf("get oauth2 provider app token by api key id: %w", err) | ||
| } | ||
|
|
||
| // Verify the token belongs to the requesting app | ||
| //nolint:gocritic // Using AsSystemOAuth2 for OAuth2 public token revocation endpoint | ||
| appSecret, err := db.GetOAuth2ProviderAppSecretByID(dbauthz.AsSystemOAuth2(ctx), dbToken.AppSecretID) | ||
| if err != nil { | ||
| return xerrors.Errorf("get oauth2 provider app secret for api key verification: %w", err) | ||
| } | ||
|
|
||
| if appSecret.AppID != appID { | ||
| return ErrTokenNotBelongsToClient | ||
| } | ||
|
|
||
| // Delete the API key | ||
| //nolint:gocritic // Using AsSystemOAuth2 for OAuth2 public token revocation endpoint | ||
| err = db.DeleteAPIKeyByID(dbauthz.AsSystemOAuth2(ctx), apiKey.ID) | ||
| if err != nil && !errors.Is(err, sql.ErrNoRows) { | ||
| return xerrors.Errorf("delete api key for revocation: %w", err) | ||
| } | ||
|
|
||
| return nil | ||
| } |
There was a problem hiding this comment.
Don't we need to verify the api key secret is correct?
This only checks the id
| if err != nil { | ||
| // Log the error for debugging, but don't fail the test | ||
| t.Logf("Token revocation error (this is expected for now): %v", err) | ||
| t.Logf("Client ID: %s, Token: %s", s.app.ID.String(), token.RefreshToken) | ||
| } |
| err = client.RevokeOAuth2Token(ctx, s.app.ID.String(), token.RefreshToken, "refresh_token") | ||
| if err != nil { | ||
| // Log the error for debugging, but don't fail the test | ||
| t.Logf("Token revocation error (this is expected for now): %v", err) | ||
| t.Logf("Client ID: %s, Token: %s", s.app.ID.String(), token.RefreshToken) | ||
| } |
There was a problem hiding this comment.
This test passes if you comment out
err = client.RevokeOAuth2Token(ctx, s.app.ID.String(), token.RefreshToken, "refresh_token")So I don't think this is actually testing anything?
|
Removing my review due to PR being stale, feel free to add me back if this becomes relevant again. |
|
some of this is done, I'm going to close this |

feat: standardize OAuth2 endpoints and add token revocation
feat: implement OAuth2 Device Authorization Grant (RFC 8628)
chore: add OAuth2 device flow test scripts
Change-Id: Ic232851727e683ab3d8b7ce970c505588da2f827
Change-Id: I7a7eebeb23a4f28718ebed2994d01dc21b49315b
Signed-off-by: Thomas Kosiewski tk@coder.com
Summary by CodeRabbit
New Features
Bug Fixes
Documentation
Tests
Style
Chores