diff --git a/src/Exceptionless.Core/Configuration/OAuthServerOptions.cs b/src/Exceptionless.Core/Configuration/OAuthServerOptions.cs index 35d5c75adb..06873920ce 100644 --- a/src/Exceptionless.Core/Configuration/OAuthServerOptions.cs +++ b/src/Exceptionless.Core/Configuration/OAuthServerOptions.cs @@ -1,3 +1,4 @@ +using Exceptionless.Core.Services; using Microsoft.Extensions.Configuration; namespace Exceptionless.Core.Configuration; @@ -7,8 +8,11 @@ public class OAuthServerOptions public TimeSpan AuthorizationCodeLifetime { get; internal set; } = TimeSpan.FromMinutes(5); public TimeSpan AccessTokenLifetime { get; internal set; } = TimeSpan.FromHours(1); public TimeSpan RefreshTokenLifetime { get; internal set; } = TimeSpan.FromDays(30); + public TimeSpan DeviceCodeLifetime { get; internal set; } = TimeSpan.FromMinutes(15); + public TimeSpan DeviceCodePollingInterval { get; internal set; } = TimeSpan.FromSeconds(5); public bool EnableClientIdMetadataDocuments { get; internal set; } = true; public int DynamicClientRegistrationIpLimit { get; internal set; } = 20; + public int DeviceAuthorizationIpLimit { get; internal set; } = 120; public TimeSpan ClientMetadataDocumentCacheLifetime { get; internal set; } = TimeSpan.FromHours(1); public TimeSpan ClientMetadataDocumentRequestTimeout { get; internal set; } = TimeSpan.FromSeconds(5); public int ClientMetadataDocumentMaxBytes { get; internal set; } = 32 * 1024; @@ -19,8 +23,11 @@ public static OAuthServerOptions ReadFromConfiguration(IConfiguration config) options.AuthorizationCodeLifetime = TimeSpan.FromMinutes(config.GetValue("OAuthServer:AuthorizationCodeLifetimeMinutes", 5)); options.AccessTokenLifetime = TimeSpan.FromMinutes(config.GetValue("OAuthServer:AccessTokenLifetimeMinutes", 60)); options.RefreshTokenLifetime = TimeSpan.FromDays(config.GetValue("OAuthServer:RefreshTokenLifetimeDays", 30)); + options.DeviceCodeLifetime = TimeSpan.FromMinutes(config.GetValue("OAuthServer:DeviceCodeLifetimeMinutes", 15)); + options.DeviceCodePollingInterval = TimeSpan.FromSeconds(config.GetValue("OAuthServer:DeviceCodePollingIntervalSeconds", 5)); options.EnableClientIdMetadataDocuments = config.GetValue("OAuthServer:EnableClientIdMetadataDocuments", true); options.DynamicClientRegistrationIpLimit = config.GetValue("OAuthServer:DynamicClientRegistrationIpLimit", 20); + options.DeviceAuthorizationIpLimit = config.GetValue("OAuthServer:DeviceAuthorizationIpLimit", 120); options.ClientMetadataDocumentCacheLifetime = TimeSpan.FromMinutes(config.GetValue("OAuthServer:ClientMetadataDocumentCacheLifetimeMinutes", 60)); options.ClientMetadataDocumentRequestTimeout = TimeSpan.FromSeconds(config.GetValue("OAuthServer:ClientMetadataDocumentRequestTimeoutSeconds", 5)); options.ClientMetadataDocumentMaxBytes = config.GetValue("OAuthServer:ClientMetadataDocumentMaxBytes", 32 * 1024); @@ -34,6 +41,7 @@ public record OAuthClientOptions public required string Name { get; init; } public IReadOnlyCollection RedirectUris { get; init; } = []; public IReadOnlyCollection Scopes { get; init; } = []; + public IReadOnlyCollection GrantTypes { get; init; } = [OAuthGrantTypes.AuthorizationCode, OAuthGrantTypes.RefreshToken]; public bool IsDisabled { get; init; } internal OAuthClientOptions Normalize() @@ -49,6 +57,11 @@ internal OAuthClientOptions Normalize() .Where(s => !String.IsNullOrWhiteSpace(s)) .Select(s => s.Trim().ToLowerInvariant()) .Distinct(StringComparer.Ordinal) + .ToArray(), + GrantTypes = GrantTypes + .Where(g => !String.IsNullOrWhiteSpace(g)) + .Select(g => g.Trim()) + .Distinct(StringComparer.Ordinal) .ToArray() }; } diff --git a/src/Exceptionless.Core/Models/OAuthApplication.cs b/src/Exceptionless.Core/Models/OAuthApplication.cs index 4abf9f1933..078c7874b0 100644 --- a/src/Exceptionless.Core/Models/OAuthApplication.cs +++ b/src/Exceptionless.Core/Models/OAuthApplication.cs @@ -1,6 +1,7 @@ using System.ComponentModel.DataAnnotations; using Exceptionless.Core.Attributes; using Exceptionless.Core.Authorization; +using Exceptionless.Core.Services; using Foundatio.Repositories.Models; namespace Exceptionless.Core.Models; @@ -21,13 +22,17 @@ public class OAuthApplication : IIdentity, IHaveDates, IValidatableObject public string Name { get; set; } = null!; [Required] - [Length(1, 20)] + [Length(0, 20)] public string[] RedirectUris { get; set; } = []; [Required] [Length(1, 20)] public string[] Scopes { get; set; } = []; + [Required] + [Length(1, 3)] + public string[] GrantTypes { get; set; } = [OAuthGrantTypes.AuthorizationCode, OAuthGrantTypes.RefreshToken]; + [MaxLength(1000)] public string? Notes { get; set; } @@ -44,6 +49,29 @@ public class OAuthApplication : IIdentity, IHaveDates, IValidatableObject public IEnumerable Validate(ValidationContext validationContext) { + foreach (string _ in GrantTypes.Where(String.IsNullOrWhiteSpace)) + yield return new ValidationResult("Grant type cannot be empty.", [nameof(GrantTypes)]); + + foreach (string grantType in GrantTypes.Where(g => !String.IsNullOrWhiteSpace(g))) + { + if (!OAuthGrantTypes.SupportedGrantTypes.Contains(grantType, StringComparer.Ordinal)) + yield return new ValidationResult($"'{grantType}' is not a supported OAuth grant type.", [nameof(GrantTypes)]); + } + + bool supportsAuthorizationCode = GrantTypes.Contains(OAuthGrantTypes.AuthorizationCode, StringComparer.Ordinal); + bool supportsDeviceCode = GrantTypes.Contains(OAuthGrantTypes.DeviceCode, StringComparer.Ordinal); + if (!supportsAuthorizationCode && !supportsDeviceCode) + yield return new ValidationResult("OAuth applications must support authorization_code or device_code.", [nameof(GrantTypes)]); + + if (GrantTypes.Contains(OAuthGrantTypes.RefreshToken, StringComparer.Ordinal) && !supportsAuthorizationCode && !supportsDeviceCode) + yield return new ValidationResult("The refresh_token grant type requires authorization_code or device_code.", [nameof(GrantTypes)]); + + if (Scopes.Contains(AuthorizationRoles.OfflineAccess, StringComparer.Ordinal) && !GrantTypes.Contains(OAuthGrantTypes.RefreshToken, StringComparer.Ordinal)) + yield return new ValidationResult("The offline_access scope requires the refresh_token grant type.", [nameof(Scopes)]); + + if (supportsAuthorizationCode && RedirectUris.Length == 0) + yield return new ValidationResult("Redirect URIs are required for authorization_code clients.", [nameof(RedirectUris)]); + foreach (string _ in RedirectUris.Where(String.IsNullOrWhiteSpace)) yield return new ValidationResult("Redirect URI cannot be empty.", [nameof(RedirectUris)]); diff --git a/src/Exceptionless.Core/Repositories/Configuration/Indexes/OAuthApplicationIndex.cs b/src/Exceptionless.Core/Repositories/Configuration/Indexes/OAuthApplicationIndex.cs index 92c2a4d0eb..efd348f9c2 100644 --- a/src/Exceptionless.Core/Repositories/Configuration/Indexes/OAuthApplicationIndex.cs +++ b/src/Exceptionless.Core/Repositories/Configuration/Indexes/OAuthApplicationIndex.cs @@ -27,6 +27,7 @@ public override void ConfigureIndexMapping(TypeMappingDescriptor e.ClientId) .Keyword(e => e.RedirectUris) .Keyword(e => e.Scopes) + .Keyword(e => e.GrantTypes) .Keyword(e => e.CreatedByUserId) .Keyword(e => e.UpdatedByUserId) .Boolean(e => e.IsDisabled)); diff --git a/src/Exceptionless.Core/Services/OAuthService.cs b/src/Exceptionless.Core/Services/OAuthService.cs index b6a1d53423..2c3d092675 100644 --- a/src/Exceptionless.Core/Services/OAuthService.cs +++ b/src/Exceptionless.Core/Services/OAuthService.cs @@ -70,14 +70,22 @@ public class OAuthService(OAuthServerOptions options, ICacheClient cacheClient, ]; private const string AuthorizationCodeCachePrefix = "oauth:code:"; + private const string DeviceCodeCachePrefix = "oauth:device:"; + private const string DeviceUserCodeCachePrefix = "oauth:user-code:"; + private const string DeviceCodeLockPrefix = "oauth:device-lock:"; private const string RefreshTokenLockPrefix = "oauth:refresh:"; private const string AccessTokenClientValidityCachePrefix = "oauth:client-valid:"; private const int OAuthGrantFamilyPageLimit = 1000; + private const int DeviceUserCodeLength = 8; + private const int DeviceUserCodeGroupLength = 4; private static readonly TimeSpan AccessTokenClientValidityCacheLifetime = TimeSpan.FromSeconds(30); + private static readonly TimeSpan RefreshTokenReplayGracePeriod = TimeSpan.FromMinutes(2); private const string ClientMetadataNotes = "Discovered from OAuth client metadata document."; private const string DynamicClientRegistrationNotes = "Registered through OAuth dynamic client registration."; + private const string DeviceUserCodeCharacters = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; private static readonly Regex CodeChallengeRegex = new("^[A-Za-z0-9_-]{43}$", RegexOptions.Compiled | RegexOptions.CultureInvariant); private static readonly Regex CodeVerifierRegex = new("^[A-Za-z0-9._~-]{43,128}$", RegexOptions.Compiled | RegexOptions.CultureInvariant); + private static readonly Regex DeviceUserCodeRegex = new("^[A-Z2-9]{8}$", RegexOptions.Compiled | RegexOptions.CultureInvariant); public bool ClientIdMetadataDocumentSupported => options.EnableClientIdMetadataDocuments; @@ -87,34 +95,40 @@ public async Task RegisterClientAsync(OAuthClient if (!String.Equals(tokenEndpointAuthMethod, "none", StringComparison.Ordinal)) return OAuthClientRegistrationResult.Invalid("invalid_client_metadata", "Only public OAuth clients using token_endpoint_auth_method 'none' are supported."); - if (request.GrantTypes is { Length: > 0 } && !request.GrantTypes.All(g => g is OAuthGrantTypes.AuthorizationCode or OAuthGrantTypes.RefreshToken)) - return OAuthClientRegistrationResult.Invalid("invalid_client_metadata", "Only authorization_code and refresh_token grant types are supported."); + var grantTypes = NormalizeGrantTypes(request.GrantTypes); + if (!ValidateGrantTypeShape(grantTypes, out string? grantTypesError)) + return OAuthClientRegistrationResult.Invalid("invalid_client_metadata", grantTypesError); - if (request.GrantTypes is { Length: > 0 } && !request.GrantTypes.Contains(OAuthGrantTypes.AuthorizationCode, StringComparer.Ordinal)) - return OAuthClientRegistrationResult.Invalid("invalid_client_metadata", "The authorization_code grant type is required."); + bool supportsAuthorizationCode = grantTypes.Contains(OAuthGrantTypes.AuthorizationCode, StringComparer.Ordinal); if (request.ResponseTypes is { Length: > 0 } && !request.ResponseTypes.SequenceEqual(["code"])) return OAuthClientRegistrationResult.Invalid("invalid_client_metadata", "Only the code response type is supported."); - if (request.RedirectUris is null || request.RedirectUris.Length == 0) - return OAuthClientRegistrationResult.Invalid("invalid_redirect_uri", "At least one redirect_uri is required."); + if (!supportsAuthorizationCode && request.ResponseTypes is { Length: > 0 }) + return OAuthClientRegistrationResult.Invalid("invalid_client_metadata", "The code response type requires the authorization_code grant type."); - string[] redirectUris = request.RedirectUris + string[] redirectUris = (request.RedirectUris ?? []) .Where(uri => !String.IsNullOrWhiteSpace(uri)) .Select(uri => uri.Trim()) .Distinct(StringComparer.Ordinal) .ToArray(); - if (redirectUris.Length == 0 || redirectUris.Length > 20 || redirectUris.Any(uri => !OAuthApplication.IsValidRedirectUri(uri))) + if (supportsAuthorizationCode && redirectUris.Length == 0) + return OAuthClientRegistrationResult.Invalid("invalid_redirect_uri", "At least one redirect_uri is required for authorization_code clients."); + + if (redirectUris.Length > 20 || redirectUris.Any(uri => !OAuthApplication.IsValidRedirectUri(uri))) return OAuthClientRegistrationResult.Invalid("invalid_redirect_uri", "Redirect URIs must be absolute HTTPS URIs or loopback HTTP URIs without fragments."); var scopes = NormalizeScopes(request.Scope); if (scopes.Count == 0) - scopes = DefaultScopes; + scopes = GetDefaultScopes(grantTypes); if (scopes.Any(s => !SupportedScopes.Contains(s, StringComparer.Ordinal))) return OAuthClientRegistrationResult.Invalid("invalid_client_metadata", "One or more scopes are not supported."); + if (scopes.Contains(AuthorizationRoles.OfflineAccess, StringComparer.Ordinal) && !grantTypes.Contains(OAuthGrantTypes.RefreshToken, StringComparer.Ordinal)) + return OAuthClientRegistrationResult.Invalid("invalid_client_metadata", "The offline_access scope requires the refresh_token grant type."); + var utcNow = timeProvider.GetUtcNow().UtcDateTime; string clientId = await CreateUniqueClientIdAsync(); var application = new OAuthApplication @@ -124,6 +138,7 @@ public async Task RegisterClientAsync(OAuthClient Name = NormalizeClientName(request.ClientName, clientId), RedirectUris = redirectUris, Scopes = scopes.ToArray(), + GrantTypes = grantTypes.ToArray(), Notes = DynamicClientRegistrationNotes, CreatedByUserId = OAuthApplication.SystemUserId, UpdatedByUserId = OAuthApplication.SystemUserId, @@ -138,8 +153,8 @@ public async Task RegisterClientAsync(OAuthClient ClientId = application.ClientId, ClientName = application.Name, RedirectUris = application.RedirectUris, - GrantTypes = [OAuthGrantTypes.AuthorizationCode, OAuthGrantTypes.RefreshToken], - ResponseTypes = ["code"], + GrantTypes = application.GrantTypes, + ResponseTypes = supportsAuthorizationCode ? ["code"] : [], Scope = String.Join(' ', application.Scopes), TokenEndpointAuthMethod = "none", ClientIdIssuedAt = new DateTimeOffset(application.CreatedUtc).ToUnixTimeSeconds() @@ -219,7 +234,8 @@ private async Task RefreshObservedApplicationAsync(OAuthApplic bool changed = !String.Equals(application.Name, observedApplication.Name, StringComparison.Ordinal) || !HasSameValues(application.RedirectUris, observedApplication.RedirectUris) - || !HasSameValues(application.Scopes, observedApplication.Scopes); + || !HasSameValues(application.Scopes, observedApplication.Scopes) + || !HasSameValues(application.GrantTypes, observedApplication.GrantTypes); if (!changed) return application; @@ -227,6 +243,7 @@ private async Task RefreshObservedApplicationAsync(OAuthApplic application.Name = observedApplication.Name; application.RedirectUris = observedApplication.RedirectUris; application.Scopes = observedApplication.Scopes; + application.GrantTypes = observedApplication.GrantTypes; application.UpdatedByUserId = OAuthApplication.SystemUserId; application.UpdatedUtc = timeProvider.GetUtcNow().UtcDateTime; @@ -242,12 +259,17 @@ private bool TryCreateObservedApplication(string clientId, OAuthClientMetadataDo if (!String.Equals(metadata.ClientId, clientId, StringComparison.Ordinal)) return false; - if (metadata.GrantTypes is { Length: > 0 } && !metadata.GrantTypes.Contains(OAuthGrantTypes.AuthorizationCode, StringComparer.Ordinal)) + var grantTypes = NormalizeGrantTypes(metadata.GrantTypes); + if (!ValidateGrantTypeShape(grantTypes, out _)) return false; + bool supportsAuthorizationCode = grantTypes.Contains(OAuthGrantTypes.AuthorizationCode, StringComparer.Ordinal); if (metadata.ResponseTypes is { Length: > 0 } && !metadata.ResponseTypes.Contains("code", StringComparer.Ordinal)) return false; + if (!supportsAuthorizationCode && metadata.ResponseTypes is { Length: > 0 }) + return false; + if (!String.IsNullOrWhiteSpace(metadata.TokenEndpointAuthMethod) && !String.Equals(metadata.TokenEndpointAuthMethod, "none", StringComparison.Ordinal)) return false; @@ -257,17 +279,20 @@ private bool TryCreateObservedApplication(string clientId, OAuthClientMetadataDo .Take(20) .ToArray() ?? []; - if (redirectUris.Length == 0) + if (supportsAuthorizationCode && redirectUris.Length == 0) return false; var metadataScopes = NormalizeScopes(metadata.Scope); string[] scopes = metadataScopes.Count > 0 ? metadataScopes.Where(s => SupportedScopes.Contains(s, StringComparer.Ordinal)).Distinct(StringComparer.Ordinal).ToArray() - : DefaultScopes.ToArray(); + : GetDefaultScopes(grantTypes).ToArray(); if (scopes.Length == 0) return false; + if (scopes.Contains(AuthorizationRoles.OfflineAccess, StringComparer.Ordinal) && !grantTypes.Contains(OAuthGrantTypes.RefreshToken, StringComparer.Ordinal)) + return false; + var utcNow = timeProvider.GetUtcNow().UtcDateTime; application = new OAuthApplication { @@ -276,6 +301,7 @@ private bool TryCreateObservedApplication(string clientId, OAuthClientMetadataDo Name = NormalizeClientName(metadata.ClientName, clientId), RedirectUris = redirectUris, Scopes = scopes, + GrantTypes = grantTypes.ToArray(), Notes = ClientMetadataNotes, CreatedByUserId = OAuthApplication.SystemUserId, UpdatedByUserId = OAuthApplication.SystemUserId, @@ -294,6 +320,7 @@ private static OAuthClientOptions MapClient(OAuthApplication application) Name = application.Name, RedirectUris = application.RedirectUris, Scopes = application.Scopes, + GrantTypes = application.GrantTypes, IsDisabled = application.IsDisabled }.Normalize(); } @@ -324,6 +351,34 @@ public IReadOnlyCollection GetAllowedScopes(OAuthClientOptions client) return client.Scopes; } + private static IReadOnlyCollection GetDefaultScopes(IReadOnlyCollection grantTypes) + { + return grantTypes.Contains(OAuthGrantTypes.RefreshToken, StringComparer.Ordinal) + ? DefaultScopes + : DefaultScopes.Where(s => !String.Equals(s, AuthorizationRoles.OfflineAccess, StringComparison.Ordinal)).ToArray(); + } + + private IReadOnlyCollection GetDefaultScopes(OAuthClientOptions client, OAuthResourceDefinition resourceDefinition) + { + var allowedScopes = GetAllowedScopes(client); + return GetDefaultScopes(client.GrantTypes) + .Where(s => allowedScopes.Contains(s, StringComparer.Ordinal)) + .Where(s => resourceDefinition.Scopes.Contains(s, StringComparer.Ordinal)) + .ToArray(); + } + + public static IReadOnlyCollection NormalizeGrantTypes(IReadOnlyCollection? grantTypes) + { + if (grantTypes is null || grantTypes.Count == 0) + return [OAuthGrantTypes.AuthorizationCode, OAuthGrantTypes.RefreshToken]; + + return grantTypes + .Where(g => !String.IsNullOrWhiteSpace(g)) + .Select(g => g.Trim()) + .Distinct(StringComparer.Ordinal) + .ToArray(); + } + public IReadOnlyCollection NormalizeScopes(string? scopes) { if (String.IsNullOrWhiteSpace(scopes)) @@ -344,6 +399,9 @@ public async Task ValidateAuthorizationRequestAsync(OAuth if (client is null) return OAuthValidationResult.Invalid("invalid_client", "Unknown OAuth client."); + if (!client.GrantTypes.Contains(OAuthGrantTypes.AuthorizationCode, StringComparer.Ordinal)) + return OAuthValidationResult.Invalid("unauthorized_client", "The client is not allowed to use the authorization_code grant type."); + if (!String.Equals(request.ResponseType, "code", StringComparison.Ordinal)) return OAuthValidationResult.Invalid("unsupported_response_type", "Only the code response type is supported."); @@ -356,7 +414,12 @@ public async Task ValidateAuthorizationRequestAsync(OAuth if (!IsExpectedResource(request.Resource, expectedResource)) return OAuthValidationResult.Invalid("invalid_target", "The requested resource is not supported."); - var requestedScopes = NormalizeScopes(request.Scope); + return ValidateRequestedScopes(client, request.Scope, resourceDefinition); + } + + private OAuthValidationResult ValidateRequestedScopes(OAuthClientOptions client, string? scope, OAuthResourceDefinition resourceDefinition) + { + var requestedScopes = NormalizeScopes(scope); if (requestedScopes.Count == 0) return OAuthValidationResult.Invalid("invalid_scope", "At least one scope is required."); @@ -364,6 +427,9 @@ public async Task ValidateAuthorizationRequestAsync(OAuth if (requestedScopes.Any(s => !allowedScopes.Contains(s, StringComparer.Ordinal))) return OAuthValidationResult.Invalid("invalid_scope", "One or more scopes are not allowed for this client."); + if (requestedScopes.Contains(AuthorizationRoles.OfflineAccess, StringComparer.Ordinal) && !client.GrantTypes.Contains(OAuthGrantTypes.RefreshToken, StringComparer.Ordinal)) + return OAuthValidationResult.Invalid("invalid_scope", "The offline_access scope requires the refresh_token grant type."); + if (resourceDefinition.RequiredScopes.Any(s => !requestedScopes.Contains(s, StringComparer.Ordinal))) return OAuthValidationResult.Invalid("invalid_scope", "One or more required resource scopes are missing."); @@ -377,6 +443,151 @@ public async Task ValidateAuthorizationRequestAsync(OAuth return OAuthValidationResult.Valid(client, requestedScopes); } + public async Task CreateDeviceAuthorizationAsync(OAuthDeviceAuthorizationRequest request, string expectedResource, OAuthResourceDefinition resourceDefinition, string verificationUri) + { + if (String.IsNullOrWhiteSpace(request.ClientId)) + return OAuthDeviceAuthorizationIssueResult.Invalid("invalid_request", "Missing client_id."); + + var client = await GetClientAsync(request.ClientId, allowClientMetadataDocument: true); + if (client is null) + return OAuthDeviceAuthorizationIssueResult.Invalid("invalid_client", "Unknown OAuth client."); + + if (!client.GrantTypes.Contains(OAuthGrantTypes.DeviceCode, StringComparer.Ordinal)) + return OAuthDeviceAuthorizationIssueResult.Invalid("unauthorized_client", "The client is not allowed to use the device_code grant type."); + + if (!IsExpectedResource(request.Resource, expectedResource)) + return OAuthDeviceAuthorizationIssueResult.Invalid("invalid_target", "The requested resource is not supported."); + + string? requestedScope = String.IsNullOrWhiteSpace(request.Scope) + ? String.Join(' ', GetDefaultScopes(client, resourceDefinition)) + : request.Scope; + + var validation = ValidateRequestedScopes(client, requestedScope, resourceDefinition); + if (!validation.IsValid) + return OAuthDeviceAuthorizationIssueResult.Invalid(validation.Error!, validation.ErrorDescription!); + + string deviceCode = CreateOAuthToken(); + string deviceCodeHash = CreateTokenHash(deviceCode); + string userCode = await CreateUniqueDeviceUserCodeAsync(); + var utcNow = timeProvider.GetUtcNow().UtcDateTime; + var authorization = new OAuthDeviceAuthorization + { + ClientId = client.ClientId, + Resource = expectedResource, + Scopes = validation.Scopes, + UserCode = FormatDeviceUserCode(userCode), + UserCodeNormalized = userCode, + Status = OAuthDeviceAuthorizationStatus.Pending, + PollingIntervalSeconds = (int)options.DeviceCodePollingInterval.TotalSeconds, + CreatedUtc = utcNow, + UpdatedUtc = utcNow, + ExpiresUtc = utcNow.Add(options.DeviceCodeLifetime) + }; + + await SetDeviceAuthorizationAsync(deviceCodeHash, authorization); + return OAuthDeviceAuthorizationIssueResult.Success(new OAuthDeviceAuthorizationResponse + { + DeviceCode = deviceCode, + UserCode = authorization.UserCode, + VerificationUri = verificationUri, + VerificationUriComplete = $"{verificationUri}?user_code={Uri.EscapeDataString(authorization.UserCode)}", + ExpiresIn = (int)options.DeviceCodeLifetime.TotalSeconds, + Interval = (int)options.DeviceCodePollingInterval.TotalSeconds + }); + } + + public async Task GetDeviceConsentAsync(string? userCode) + { + var lookup = await GetDeviceAuthorizationByUserCodeAsync(userCode); + if (!lookup.IsSuccess) + return OAuthDeviceConsentResult.Invalid(lookup.Error!, lookup.ErrorDescription!); + + var authorization = lookup.Authorization!; + if (authorization.Status != OAuthDeviceAuthorizationStatus.Pending) + return OAuthDeviceConsentResult.Invalid("expired_token", "Device authorization is no longer pending."); + + var client = await GetClientAsync(authorization.ClientId); + if (client is null) + return OAuthDeviceConsentResult.Invalid("invalid_client", "Unknown OAuth client."); + + if (!TryGetProtectedResourceByResourceUri(authorization.Resource, out var resourceDefinition)) + return OAuthDeviceConsentResult.Invalid("invalid_target", "The requested resource is not supported."); + + return OAuthDeviceConsentResult.Valid(client, authorization, resourceDefinition); + } + + public async Task ApproveDeviceAuthorizationAsync(OAuthDeviceApprovalRequest request, string userId) + { + var lookup = await GetDeviceAuthorizationByUserCodeAsync(request.UserCode); + if (!lookup.IsSuccess) + return OAuthDeviceAuthorizationResult.Invalid(lookup.Error!, lookup.ErrorDescription!); + + await using var deviceCodeLock = await lockProvider.TryAcquireAsync(GetDeviceCodeLockKey(lookup.DeviceCodeHash!), TimeSpan.FromSeconds(30), CancellationToken.None); + if (deviceCodeLock is null) + return OAuthDeviceAuthorizationResult.Invalid("temporarily_unavailable", "Device authorization is being processed."); + + lookup = await GetDeviceAuthorizationByDeviceCodeHashAsync(lookup.DeviceCodeHash!); + if (!lookup.IsSuccess) + return OAuthDeviceAuthorizationResult.Invalid(lookup.Error!, lookup.ErrorDescription!); + + var authorization = lookup.Authorization!; + if (authorization.Status != OAuthDeviceAuthorizationStatus.Pending) + return OAuthDeviceAuthorizationResult.Invalid("expired_token", "Device authorization is no longer pending."); + + var client = await GetClientAsync(authorization.ClientId); + if (client is null) + return OAuthDeviceAuthorizationResult.Invalid("invalid_client", "Unknown OAuth client."); + + if (!client.GrantTypes.Contains(OAuthGrantTypes.DeviceCode, StringComparer.Ordinal)) + return OAuthDeviceAuthorizationResult.Invalid("unauthorized_client", "The client is not allowed to use the device_code grant type."); + + if (!TryGetProtectedResourceByResourceUri(authorization.Resource, out var resourceDefinition)) + return OAuthDeviceAuthorizationResult.Invalid("invalid_target", "The requested resource is not supported."); + + var requestedScopes = NormalizeScopes(request.Scope); + if (requestedScopes.Count == 0) + return OAuthDeviceAuthorizationResult.Invalid("invalid_scope", "At least one scope is required."); + + if (requestedScopes.Any(scope => !authorization.Scopes.Contains(scope, StringComparer.Ordinal))) + return OAuthDeviceAuthorizationResult.Invalid("invalid_scope", "One or more scopes were not requested by the device."); + + var scopeValidation = ValidateRequestedScopes(client, String.Join(' ', requestedScopes), resourceDefinition); + if (!scopeValidation.IsValid) + return OAuthDeviceAuthorizationResult.Invalid(scopeValidation.Error!, scopeValidation.ErrorDescription!); + + authorization.Status = OAuthDeviceAuthorizationStatus.Approved; + authorization.UserId = userId; + authorization.Scopes = scopeValidation.Scopes; + authorization.OrganizationIds = request.OrganizationIds.ToArray(); + authorization.UpdatedUtc = timeProvider.GetUtcNow().UtcDateTime; + await SetDeviceAuthorizationAsync(lookup.DeviceCodeHash!, authorization); + return OAuthDeviceAuthorizationResult.Success(); + } + + public async Task DenyDeviceAuthorizationAsync(string? userCode) + { + var lookup = await GetDeviceAuthorizationByUserCodeAsync(userCode); + if (!lookup.IsSuccess) + return OAuthDeviceAuthorizationResult.Invalid(lookup.Error!, lookup.ErrorDescription!); + + await using var deviceCodeLock = await lockProvider.TryAcquireAsync(GetDeviceCodeLockKey(lookup.DeviceCodeHash!), TimeSpan.FromSeconds(30), CancellationToken.None); + if (deviceCodeLock is null) + return OAuthDeviceAuthorizationResult.Invalid("temporarily_unavailable", "Device authorization is being processed."); + + lookup = await GetDeviceAuthorizationByDeviceCodeHashAsync(lookup.DeviceCodeHash!); + if (!lookup.IsSuccess) + return OAuthDeviceAuthorizationResult.Invalid(lookup.Error!, lookup.ErrorDescription!); + + var authorization = lookup.Authorization!; + if (authorization.Status != OAuthDeviceAuthorizationStatus.Pending) + return OAuthDeviceAuthorizationResult.Invalid("expired_token", "Device authorization is no longer pending."); + + authorization.Status = OAuthDeviceAuthorizationStatus.Denied; + authorization.UpdatedUtc = timeProvider.GetUtcNow().UtcDateTime; + await SetDeviceAuthorizationAsync(lookup.DeviceCodeHash!, authorization); + return OAuthDeviceAuthorizationResult.Success(); + } + public async Task CreateAuthorizationCodeAsync(OAuthAuthorizeRequest request, string userId, IReadOnlyCollection organizationIds) { string code = StringExtensions.GetNewToken(); @@ -403,9 +614,13 @@ public async Task ExchangeAuthorizationCodeAsync(OAuthTok if (String.IsNullOrWhiteSpace(request.Code) || String.IsNullOrWhiteSpace(request.CodeVerifier) || String.IsNullOrWhiteSpace(request.RedirectUri) || String.IsNullOrWhiteSpace(request.ClientId) || String.IsNullOrWhiteSpace(request.Resource)) return OAuthTokenIssueResult.Invalid("invalid_request", "Missing required token request fields."); - if (await GetClientAsync(request.ClientId) is null) + var client = await GetClientAsync(request.ClientId); + if (client is null) return OAuthTokenIssueResult.Invalid("invalid_client", "Unknown OAuth client."); + if (!client.GrantTypes.Contains(OAuthGrantTypes.AuthorizationCode, StringComparer.Ordinal)) + return OAuthTokenIssueResult.Invalid("unauthorized_client", "The client is not allowed to use the authorization_code grant type."); + if (!IsValidCodeVerifier(request.CodeVerifier)) return OAuthTokenIssueResult.Invalid("invalid_grant", "Invalid PKCE verifier."); @@ -425,6 +640,71 @@ public async Task ExchangeAuthorizationCodeAsync(OAuthTok return OAuthTokenIssueResult.Success(await CreateTokenAsync(code.UserId, code.ClientId, code.Resource, code.Scopes, code.OrganizationIds)); } + public async Task ExchangeDeviceCodeAsync(OAuthTokenRequest request) + { + if (!String.Equals(request.GrantType, OAuthGrantTypes.DeviceCode, StringComparison.Ordinal)) + return OAuthTokenIssueResult.Invalid("unsupported_grant_type", "Unsupported grant_type."); + + if (String.IsNullOrWhiteSpace(request.ClientId) || String.IsNullOrWhiteSpace(request.DeviceCode)) + return OAuthTokenIssueResult.Invalid("invalid_request", "Missing client_id or device_code."); + + var client = await GetClientAsync(request.ClientId); + if (client is null) + return OAuthTokenIssueResult.Invalid("invalid_client", "Unknown OAuth client."); + + if (!client.GrantTypes.Contains(OAuthGrantTypes.DeviceCode, StringComparer.Ordinal)) + return OAuthTokenIssueResult.Invalid("unauthorized_client", "The client is not allowed to use the device_code grant type."); + + string deviceCodeHash = CreateTokenHash(request.DeviceCode); + await using var deviceCodeLock = await lockProvider.TryAcquireAsync(GetDeviceCodeLockKey(deviceCodeHash), TimeSpan.FromSeconds(30), CancellationToken.None); + if (deviceCodeLock is null) + return OAuthTokenIssueResult.Invalid("authorization_pending", "Device authorization is being processed."); + + var lookup = await GetDeviceAuthorizationByDeviceCodeHashAsync(deviceCodeHash); + if (!lookup.IsSuccess) + return OAuthTokenIssueResult.Invalid(lookup.Error!, lookup.ErrorDescription!); + + var authorization = lookup.Authorization!; + if (!String.Equals(authorization.ClientId, request.ClientId, StringComparison.Ordinal)) + return OAuthTokenIssueResult.Invalid("invalid_grant", "Device code does not match the token request."); + + if (authorization.Status == OAuthDeviceAuthorizationStatus.Denied) + { + await RemoveDeviceAuthorizationAsync(lookup.DeviceCodeHash!, authorization); + return OAuthTokenIssueResult.Invalid("access_denied", "The device authorization request was denied."); + } + + if (authorization.Status == OAuthDeviceAuthorizationStatus.Approved) + { + if (String.IsNullOrWhiteSpace(authorization.UserId) || authorization.OrganizationIds.Count == 0) + { + await RemoveDeviceAuthorizationAsync(lookup.DeviceCodeHash!, authorization); + return OAuthTokenIssueResult.Invalid("invalid_grant", "Device authorization is invalid."); + } + + await RemoveDeviceAuthorizationAsync(lookup.DeviceCodeHash!, authorization); + return OAuthTokenIssueResult.Success(await CreateTokenAsync(authorization.UserId, authorization.ClientId, authorization.Resource, authorization.Scopes, authorization.OrganizationIds)); + } + + var utcNow = timeProvider.GetUtcNow().UtcDateTime; + int pollingIntervalSeconds = authorization.PollingIntervalSeconds > 0 + ? authorization.PollingIntervalSeconds + : (int)options.DeviceCodePollingInterval.TotalSeconds; + if (authorization.LastPolledUtc.HasValue && utcNow - authorization.LastPolledUtc.Value < TimeSpan.FromSeconds(pollingIntervalSeconds)) + { + authorization.PollingIntervalSeconds = pollingIntervalSeconds + 5; + authorization.LastPolledUtc = utcNow; + authorization.UpdatedUtc = utcNow; + await SetDeviceAuthorizationAsync(lookup.DeviceCodeHash!, authorization); + return OAuthTokenIssueResult.Invalid("slow_down", "Poll interval exceeded."); + } + + authorization.LastPolledUtc = utcNow; + authorization.UpdatedUtc = utcNow; + await SetDeviceAuthorizationAsync(lookup.DeviceCodeHash!, authorization); + return OAuthTokenIssueResult.Invalid("authorization_pending", "Device authorization is pending."); + } + public async Task RefreshAsync(OAuthTokenRequest request) { if (String.IsNullOrWhiteSpace(request.RefreshToken) || String.IsNullOrWhiteSpace(request.ClientId)) @@ -434,6 +714,9 @@ public async Task RefreshAsync(OAuthTokenRequest request) if (client is null) return OAuthTokenIssueResult.Invalid("invalid_client", "Unknown OAuth client."); + if (!client.GrantTypes.Contains(OAuthGrantTypes.RefreshToken, StringComparer.Ordinal)) + return OAuthTokenIssueResult.Invalid("unauthorized_client", "The client is not allowed to use the refresh_token grant type."); + await using var refreshTokenLock = await lockProvider.TryAcquireAsync(GetRefreshTokenLockKey(request.RefreshToken), TimeSpan.FromSeconds(30), CancellationToken.None); if (refreshTokenLock is null) return OAuthTokenIssueResult.Invalid("invalid_grant", "Refresh token is invalid."); @@ -443,12 +726,21 @@ public async Task RefreshAsync(OAuthTokenRequest request) if (token is null || !String.Equals(token.ClientId, request.ClientId, StringComparison.Ordinal)) return OAuthTokenIssueResult.Invalid("invalid_grant", "Refresh token is invalid."); - if (token.IsDisabled || token.IsSuspended) + if (token.IsSuspended) { await RevokeOAuthGrantFamilyAsync(token); return OAuthTokenIssueResult.Invalid("invalid_grant", "Refresh token is invalid."); } + if (token.IsDisabled) + { + if (IsRecentRefreshTokenReplay(token)) + return OAuthTokenIssueResult.Invalid("invalid_grant", "Refresh token is invalid."); + + await RevokeOAuthGrantFamilyAsync(token); + return OAuthTokenIssueResult.Invalid("invalid_grant", "Refresh token is invalid."); + } + if (token.OrganizationIds.Count == 0) return OAuthTokenIssueResult.Invalid("invalid_grant", "Refresh token is invalid."); @@ -552,6 +844,14 @@ private static RefreshScopeValidationResult ValidateRefreshScopes(OAuthToken tok return RefreshScopeValidationResult.Valid(refreshedScopes); } + private bool IsRecentRefreshTokenReplay(OAuthToken token) + { + if (String.IsNullOrEmpty(token.RefreshTokenHash)) + return false; + + return token.UpdatedUtc >= timeProvider.GetUtcNow().UtcDateTime.Subtract(RefreshTokenReplayGracePeriod); + } + private Task DisableTokenAsync(OAuthToken token, bool clearRefresh = true) { token.IsDisabled = true; @@ -595,6 +895,118 @@ private async Task CreateTokenAsync(string userId, string cl Resource = resource }; } + + private async Task SetDeviceAuthorizationAsync(string deviceCodeHash, OAuthDeviceAuthorization authorization) + { + var lifetime = authorization.ExpiresUtc - timeProvider.GetUtcNow().UtcDateTime; + if (lifetime <= TimeSpan.Zero) + { + await RemoveDeviceAuthorizationAsync(deviceCodeHash, authorization); + return; + } + + await cacheClient.SetAsync(GetDeviceCodeCacheKey(deviceCodeHash), authorization, lifetime); + await cacheClient.SetAsync(GetDeviceUserCodeCacheKey(authorization.UserCodeNormalized), deviceCodeHash, lifetime); + } + + private async Task RemoveDeviceAuthorizationAsync(string deviceCodeHash, OAuthDeviceAuthorization authorization) + { + await cacheClient.RemoveAsync(GetDeviceCodeCacheKey(deviceCodeHash)); + await cacheClient.RemoveAsync(GetDeviceUserCodeCacheKey(authorization.UserCodeNormalized)); + } + + private async Task GetDeviceAuthorizationByUserCodeAsync(string? userCode) + { + string? normalizedUserCode = NormalizeDeviceUserCode(userCode); + if (normalizedUserCode is null) + return OAuthDeviceAuthorizationLookupResult.Invalid("expired_token", "Device authorization is invalid or expired."); + + string userCodeCacheKey = GetDeviceUserCodeCacheKey(normalizedUserCode); + var deviceCodeResult = await cacheClient.GetAsync(userCodeCacheKey); + if (!deviceCodeResult.HasValue) + return OAuthDeviceAuthorizationLookupResult.Invalid("expired_token", "Device authorization is invalid or expired."); + + var lookup = await GetDeviceAuthorizationByDeviceCodeHashAsync(deviceCodeResult.Value); + if (!lookup.IsSuccess) + await cacheClient.RemoveAsync(userCodeCacheKey); + + return lookup; + } + + private async Task GetDeviceAuthorizationByDeviceCodeHashAsync(string deviceCodeHash) + { + var authorizationResult = await cacheClient.GetAsync(GetDeviceCodeCacheKey(deviceCodeHash)); + if (!authorizationResult.HasValue) + return OAuthDeviceAuthorizationLookupResult.Invalid("expired_token", "Device authorization is invalid or expired."); + + var authorization = authorizationResult.Value; + if (authorization.ExpiresUtc <= timeProvider.GetUtcNow().UtcDateTime) + { + await RemoveDeviceAuthorizationAsync(deviceCodeHash, authorization); + return OAuthDeviceAuthorizationLookupResult.Invalid("expired_token", "Device authorization is invalid or expired."); + } + + return OAuthDeviceAuthorizationLookupResult.Success(deviceCodeHash, authorization); + } + + private async Task CreateUniqueDeviceUserCodeAsync() + { + for (int attempt = 0; attempt < 10; attempt++) + { + string userCode = CreateDeviceUserCode(); + var existing = await cacheClient.GetAsync(GetDeviceUserCodeCacheKey(userCode)); + if (!existing.HasValue) + return userCode; + } + + throw new InvalidOperationException("Unable to create a unique OAuth device user code."); + } + + private static string CreateDeviceUserCode() + { + Span code = stackalloc char[DeviceUserCodeLength]; + for (int i = 0; i < code.Length; i++) + code[i] = DeviceUserCodeCharacters[RandomNumberGenerator.GetInt32(DeviceUserCodeCharacters.Length)]; + + return new string(code); + } + + private static string FormatDeviceUserCode(string userCode) + { + return userCode.Insert(DeviceUserCodeGroupLength, "-"); + } + + private static string? NormalizeDeviceUserCode(string? userCode) + { + if (String.IsNullOrWhiteSpace(userCode)) + return null; + + var normalized = new string(userCode + .Where(c => c != '-' && !Char.IsWhiteSpace(c)) + .Select(Char.ToUpperInvariant) + .ToArray()); + + return DeviceUserCodeRegex.IsMatch(normalized) ? normalized : null; + } + + private static bool ValidateGrantTypeShape(IReadOnlyCollection grantTypes, out string error) + { + error = String.Empty; + if (grantTypes.Any(g => !OAuthGrantTypes.SupportedGrantTypes.Contains(g, StringComparer.Ordinal))) + { + error = "Only authorization_code, refresh_token, and device_code grant types are supported."; + return false; + } + + if (!grantTypes.Contains(OAuthGrantTypes.AuthorizationCode, StringComparer.Ordinal) && !grantTypes.Contains(OAuthGrantTypes.DeviceCode, StringComparer.Ordinal)) + { + error = "The authorization_code or device_code grant type is required."; + return false; + } + + return true; + } + private static bool ValidateCodeVerifier(string challenge, string verifier) { return String.Equals(challenge, CreateCodeChallenge(verifier), StringComparison.Ordinal); @@ -745,6 +1157,9 @@ private static string Base64UrlEncode(byte[] bytes) } private static string GetAuthorizationCodeCacheKey(string code) => AuthorizationCodeCachePrefix + code; + private static string GetDeviceCodeCacheKey(string deviceCodeHash) => DeviceCodeCachePrefix + deviceCodeHash; + private static string GetDeviceUserCodeCacheKey(string userCode) => DeviceUserCodeCachePrefix + CreateTokenHash(userCode); + private static string GetDeviceCodeLockKey(string deviceCodeHash) => DeviceCodeLockPrefix + deviceCodeHash; private static string GetRefreshTokenLockKey(string refreshToken) => RefreshTokenLockPrefix + CreateTokenHash(refreshToken); private static string GetAccessTokenClientValidityCacheKey(string clientId) => AccessTokenClientValidityCachePrefix + Convert.ToBase64String(SHA256.HashData(Encoding.UTF8.GetBytes(clientId))).TrimEnd('=').Replace('+', '-').Replace('/', '_'); } @@ -752,7 +1167,15 @@ private static string Base64UrlEncode(byte[] bytes) public static class OAuthGrantTypes { public const string AuthorizationCode = "authorization_code"; + public const string DeviceCode = "urn:ietf:params:oauth:grant-type:device_code"; public const string RefreshToken = "refresh_token"; + + public static readonly IReadOnlyCollection SupportedGrantTypes = + [ + AuthorizationCode, + DeviceCode, + RefreshToken + ]; } public record OAuthAuthorizeRequest @@ -776,9 +1199,24 @@ public record OAuthTokenRequest public string? ClientId { get; init; } public string? CodeVerifier { get; init; } public string? RefreshToken { get; init; } + public string? DeviceCode { get; init; } public string? Resource { get; init; } } +public record OAuthDeviceAuthorizationRequest +{ + public required string ClientId { get; init; } + public string? Scope { get; init; } + public string? Resource { get; init; } +} + +public record OAuthDeviceApprovalRequest +{ + public required string UserCode { get; init; } + public required string Scope { get; init; } + public IReadOnlyCollection OrganizationIds { get; init; } = []; +} + public record OAuthClientRegistrationRequest { [JsonPropertyName("redirect_uris")] @@ -833,6 +1271,27 @@ public record OAuthClientRegistrationResponse public required long ClientIdIssuedAt { get; init; } } +public record OAuthDeviceAuthorizationResponse +{ + [JsonPropertyName("device_code")] + public required string DeviceCode { get; init; } + + [JsonPropertyName("user_code")] + public required string UserCode { get; init; } + + [JsonPropertyName("verification_uri")] + public required string VerificationUri { get; init; } + + [JsonPropertyName("verification_uri_complete")] + public required string VerificationUriComplete { get; init; } + + [JsonPropertyName("expires_in")] + public required int ExpiresIn { get; init; } + + [JsonPropertyName("interval")] + public required int Interval { get; init; } +} + public record OAuthAuthorizationCode { public required string ClientId { get; init; } @@ -845,9 +1304,56 @@ public record OAuthAuthorizationCode public IReadOnlyCollection OrganizationIds { get; init; } = []; } +public class OAuthDeviceAuthorization +{ + public required string ClientId { get; init; } + public required string Resource { get; init; } + public required string UserCode { get; init; } + public required string UserCodeNormalized { get; init; } + public OAuthDeviceAuthorizationStatus Status { get; set; } + public IReadOnlyCollection Scopes { get; set; } = []; + public string? UserId { get; set; } + public IReadOnlyCollection OrganizationIds { get; set; } = []; + public int PollingIntervalSeconds { get; set; } + public DateTime? LastPolledUtc { get; set; } + public DateTime CreatedUtc { get; init; } + public DateTime UpdatedUtc { get; set; } + public DateTime ExpiresUtc { get; init; } +} + +public enum OAuthDeviceAuthorizationStatus +{ + Pending = 0, + Approved = 1, + Denied = 2 +} public sealed record OAuthResourceDefinition(string Path, IReadOnlyCollection Scopes, IReadOnlyCollection RequiredScopes); +internal sealed record OAuthDeviceAuthorizationLookupResult(bool IsSuccess, string? DeviceCodeHash, OAuthDeviceAuthorization? Authorization, string? Error, string? ErrorDescription) +{ + public static OAuthDeviceAuthorizationLookupResult Success(string deviceCodeHash, OAuthDeviceAuthorization authorization) => new(true, deviceCodeHash, authorization, null, null); + public static OAuthDeviceAuthorizationLookupResult Invalid(string error, string description) => new(false, null, null, error, description); +} + +public sealed record OAuthDeviceAuthorizationIssueResult(bool IsSuccess, OAuthDeviceAuthorizationResponse? Response, string? Error, string? ErrorDescription) +{ + public static OAuthDeviceAuthorizationIssueResult Success(OAuthDeviceAuthorizationResponse response) => new(true, response, null, null); + public static OAuthDeviceAuthorizationIssueResult Invalid(string error, string description) => new(false, null, error, description); +} + +public sealed record OAuthDeviceConsentResult(bool IsSuccess, OAuthClientOptions? Client, OAuthDeviceAuthorization? Authorization, OAuthResourceDefinition? ResourceDefinition, string? Error, string? ErrorDescription) +{ + public static OAuthDeviceConsentResult Valid(OAuthClientOptions client, OAuthDeviceAuthorization authorization, OAuthResourceDefinition resourceDefinition) => new(true, client, authorization, resourceDefinition, null, null); + public static OAuthDeviceConsentResult Invalid(string error, string description) => new(false, null, null, null, error, description); +} + +public sealed record OAuthDeviceAuthorizationResult(bool IsSuccess, string? Error, string? ErrorDescription) +{ + public static OAuthDeviceAuthorizationResult Success() => new(true, null, null); + public static OAuthDeviceAuthorizationResult Invalid(string error, string description) => new(false, error, description); +} + internal sealed record RefreshScopeValidationResult(bool IsValid, IReadOnlyCollection Scopes) { public static RefreshScopeValidationResult Valid(IReadOnlyCollection scopes) => new(true, scopes); diff --git a/src/Exceptionless.Web/Api/Endpoints/OAuthEndpoints.cs b/src/Exceptionless.Web/Api/Endpoints/OAuthEndpoints.cs index bae765585e..efdc12003b 100644 --- a/src/Exceptionless.Web/Api/Endpoints/OAuthEndpoints.cs +++ b/src/Exceptionless.Web/Api/Endpoints/OAuthEndpoints.cs @@ -3,8 +3,8 @@ using Exceptionless.Web.Api.Messages; using Exceptionless.Web.Models.OAuth; using Foundatio.Mediator; -using HttpIResult = Microsoft.AspNetCore.Http.IResult; using Microsoft.AspNetCore.Mvc; +using HttpIResult = Microsoft.AspNetCore.Http.IResult; namespace Exceptionless.Web.Api.Endpoints; @@ -69,6 +69,40 @@ public static IEndpointRouteBuilder MapOAuthEndpoints(this IEndpointRouteBuilder .Produces(StatusCodes.Status400BadRequest) .Produces(StatusCodes.Status429TooManyRequests); + group.MapPost("device_authorization", CreateDeviceAuthorizationAsync) + .AllowAnonymous() + .Accepts("application/x-www-form-urlencoded") + .Produces() + .Produces(StatusCodes.Status400BadRequest) + .Produces(StatusCodes.Status429TooManyRequests) + .DisableAntiforgery(); + + group.MapGet("device", async (IMediator mediator, [FromQuery(Name = "user_code")] string? userCode = null) + => await mediator.InvokeAsync(new RedirectToDeviceBridge(userCode))) + .AllowAnonymous() + .Produces(StatusCodes.Status302Found); + + group.MapPost("device/consent", async (IMediator mediator, [FromBody] OAuthDeviceConsentForm form) + => await mediator.InvokeAsync(new GetDeviceConsent(form))) + .RequireAuthorization(AuthorizationRoles.UserPolicy) + .Accepts("application/json", "application/*+json", "application/octet-stream", "text/json", "text/plain") + .Produces() + .Produces(StatusCodes.Status400BadRequest); + + group.MapPost("device/authorize", async (IMediator mediator, [FromBody] OAuthDeviceAuthorizeForm form) + => await mediator.InvokeAsync(new ApproveDeviceAuthorization(form))) + .RequireAuthorization(AuthorizationRoles.UserPolicy) + .Accepts("application/json", "application/*+json", "application/octet-stream", "text/json", "text/plain") + .Produces(StatusCodes.Status200OK) + .Produces(StatusCodes.Status400BadRequest); + + group.MapPost("device/deny", async (IMediator mediator, [FromBody] OAuthDeviceConsentForm form) + => await mediator.InvokeAsync(new DenyDeviceAuthorization(form))) + .RequireAuthorization(AuthorizationRoles.UserPolicy) + .Accepts("application/json", "application/*+json", "application/octet-stream", "text/json", "text/plain") + .Produces(StatusCodes.Status200OK) + .Produces(StatusCodes.Status400BadRequest); + group.MapPost("token", IssueTokenAsync) .AllowAnonymous() .Accepts("application/x-www-form-urlencoded") @@ -96,12 +130,26 @@ private static async Task IssueTokenAsync(IMediator mediator, HttpR ClientId = GetFormValue(form, "client_id"), CodeVerifier = GetFormValue(form, "code_verifier"), RefreshToken = GetFormValue(form, "refresh_token"), + DeviceCode = GetFormValue(form, "device_code"), Resource = GetFormValue(form, "resource") }; return await mediator.InvokeAsync(new IssueOAuthToken(tokenForm)); } + private static async Task CreateDeviceAuthorizationAsync(IMediator mediator, HttpRequest request, CancellationToken cancellationToken) + { + var form = await request.ReadFormAsync(cancellationToken); + var authorizationForm = new OAuthDeviceAuthorizationForm + { + ClientId = GetFormValue(form, "client_id"), + Scope = GetFormValue(form, "scope"), + Resource = GetFormValue(form, "resource") + }; + + return await mediator.InvokeAsync(new CreateDeviceAuthorization(authorizationForm)); + } + private static async Task RevokeTokenAsync(IMediator mediator, HttpRequest request, CancellationToken cancellationToken) { var form = await request.ReadFormAsync(cancellationToken); diff --git a/src/Exceptionless.Web/Api/Handlers/OAuthApplicationHandler.cs b/src/Exceptionless.Web/Api/Handlers/OAuthApplicationHandler.cs index 26e11b328a..f48f44a765 100644 --- a/src/Exceptionless.Web/Api/Handlers/OAuthApplicationHandler.cs +++ b/src/Exceptionless.Web/Api/Handlers/OAuthApplicationHandler.cs @@ -1,7 +1,7 @@ +using Exceptionless.Core.Extensions; using Exceptionless.Core.Models; using Exceptionless.Core.Repositories; using Exceptionless.Core.Services; -using Exceptionless.Core.Extensions; using Exceptionless.Core.Validation; using Exceptionless.Web.Api.Messages; using Exceptionless.Web.Extensions; @@ -38,6 +38,7 @@ public async Task> Handle(CreateOAuthApplicationMes Name = model.Name.Trim(), RedirectUris = NormalizeValues(model.RedirectUris, StringComparer.Ordinal), Scopes = NormalizeScopes(model.Scopes), + GrantTypes = OAuthService.NormalizeGrantTypes(model.GrantTypes).ToArray(), Notes = model.Notes?.Trim(), IsDisabled = model.IsDisabled, CreatedByUserId = currentUser.Id, @@ -74,6 +75,7 @@ public async Task> Handle(UpdateOAuthApplicationMes application.Name = model.Name.Trim(); application.RedirectUris = NormalizeValues(model.RedirectUris, StringComparer.Ordinal); application.Scopes = NormalizeScopes(model.Scopes); + application.GrantTypes = OAuthService.NormalizeGrantTypes(model.GrantTypes).ToArray(); application.Notes = model.Notes?.Trim(); application.IsDisabled = model.IsDisabled; application.UpdatedByUserId = message.Context.Request.GetUser().Id; @@ -110,9 +112,9 @@ private async Task IsClientIdAvailableAsync(string clientId) return existing is null; } - private static string[] NormalizeValues(IEnumerable values, IEqualityComparer comparer) + private static string[] NormalizeValues(IEnumerable? values, IEqualityComparer comparer) { - return values + return (values ?? []) .Where(v => !String.IsNullOrWhiteSpace(v)) .Select(v => v.Trim()) .Distinct(comparer) diff --git a/src/Exceptionless.Web/Api/Handlers/OAuthHandler.cs b/src/Exceptionless.Web/Api/Handlers/OAuthHandler.cs index b59b7d2289..e58eac85c0 100644 --- a/src/Exceptionless.Web/Api/Handlers/OAuthHandler.cs +++ b/src/Exceptionless.Web/Api/Handlers/OAuthHandler.cs @@ -31,9 +31,10 @@ public Task Handle(GetAuthorizationServerMetadata message) Issuer = origin, AuthorizationEndpoint = $"{origin}/api/v2/oauth/authorize", TokenEndpoint = $"{origin}/api/v2/oauth/token", + DeviceAuthorizationEndpoint = $"{origin}/api/v2/oauth/device_authorization", RegistrationEndpoint = $"{origin}/api/v2/oauth/register", RevocationEndpoint = $"{origin}/api/v2/oauth/revoke", - GrantTypesSupported = [OAuthGrantTypes.AuthorizationCode, OAuthGrantTypes.RefreshToken], + GrantTypesSupported = [OAuthGrantTypes.AuthorizationCode, OAuthGrantTypes.DeviceCode, OAuthGrantTypes.RefreshToken], ResponseTypesSupported = ["code"], CodeChallengeMethodsSupported = [OAuthService.CodeChallengeMethod], TokenEndpointAuthMethodsSupported = ["none"], @@ -99,6 +100,86 @@ public async Task Handle(RegisterOAuthClient message) return HttpResults.Json(result.Response, statusCode: StatusCodes.Status201Created); } + public async Task Handle(CreateDeviceAuthorization message) + { + if (await IsDeviceAuthorizationRateLimitedAsync()) + { + return HttpResults.Json(new OAuthErrorResponse + { + Error = "temporarily_unavailable", + ErrorDescription = "Too many device authorization attempts." + }, statusCode: StatusCodes.Status429TooManyRequests); + } + + var form = message.Form; + if (!OAuthService.TryGetProtectedResource(form.Resource, GetOrigin(), out var resourceDefinition)) + return OAuthError("invalid_target", "The requested resource is not supported."); + + var request = new OAuthDeviceAuthorizationRequest + { + ClientId = form.ClientId ?? String.Empty, + Scope = form.Scope, + Resource = form.Resource + }; + + var result = await oauthService.CreateDeviceAuthorizationAsync(request, GetResource(resourceDefinition), resourceDefinition, GetDeviceVerificationUri()); + if (!result.IsSuccess) + return OAuthError(result.Error, result.ErrorDescription); + + return HttpResults.Ok(result.Response); + } + + public Task Handle(RedirectToDeviceBridge message) + { + string query = String.IsNullOrWhiteSpace(message.UserCode) ? String.Empty : $"?user_code={Uri.EscapeDataString(message.UserCode)}"; + return Task.FromResult(HttpResults.Redirect("/next/oauth/device" + query)); + } + + public async Task Handle(GetDeviceConsent message) + { + var result = await oauthService.GetDeviceConsentAsync(message.Form.UserCode); + if (!result.IsSuccess) + return OAuthError(result.Error, result.ErrorDescription); + + return HttpResults.Ok(new OAuthDeviceConsentResponse + { + ClientId = result.Client!.ClientId, + ClientName = result.Client.Name, + UserCode = result.Authorization!.UserCode, + Resource = result.Authorization.Resource, + Scopes = result.Authorization.Scopes, + RequiredScopes = result.ResourceDefinition!.RequiredScopes + }); + } + + public async Task Handle(ApproveDeviceAuthorization message) + { + var organizationValidation = ValidateRequestedOrganizations(message.Form.OrganizationIds ?? []); + if (!organizationValidation.IsValid) + return OAuthError("invalid_request", organizationValidation.ErrorDescription); + + var result = await oauthService.ApproveDeviceAuthorizationAsync(new OAuthDeviceApprovalRequest + { + UserCode = message.Form.UserCode ?? String.Empty, + Scope = message.Form.Scope ?? String.Empty, + OrganizationIds = organizationValidation.OrganizationIds + }, HttpContext.Request.GetUser().Id); + + if (!result.IsSuccess) + return OAuthError(result.Error, result.ErrorDescription); + + return HttpResults.Ok(); + } + + public async Task Handle(DenyDeviceAuthorization message) + { + var result = await oauthService.DenyDeviceAuthorizationAsync(message.Form.UserCode); + if (!result.IsSuccess) + return OAuthError(result.Error, result.ErrorDescription); + + return HttpResults.Ok(); + } + public async Task Handle(IssueOAuthToken message) { var form = message.Form; @@ -110,12 +191,16 @@ public async Task Handle(IssueOAuthToken message) ClientId = form.ClientId, CodeVerifier = form.CodeVerifier, RefreshToken = form.RefreshToken, + DeviceCode = form.DeviceCode, Resource = form.Resource }; - OAuthTokenIssueResult result = String.Equals(form.GrantType, OAuthGrantTypes.RefreshToken, StringComparison.Ordinal) - ? await oauthService.RefreshAsync(request) - : await oauthService.ExchangeAuthorizationCodeAsync(request); + OAuthTokenIssueResult result = form.GrantType switch + { + OAuthGrantTypes.RefreshToken => await oauthService.RefreshAsync(request), + OAuthGrantTypes.DeviceCode => await oauthService.ExchangeDeviceCodeAsync(request), + _ => await oauthService.ExchangeAuthorizationCodeAsync(request) + }; if (!result.IsSuccess) return OAuthError(result.Error, result.ErrorDescription); @@ -150,6 +235,13 @@ private async Task IsDynamicClientRegistrationRateLimitedAsync() return attempts > appOptions.OAuthServerOptions.DynamicClientRegistrationIpLimit; } + private async Task IsDeviceAuthorizationRateLimitedAsync() + { + string cacheKey = $"ip:{HttpContext.Request.GetClientIpAddress()}:oauth-device:attempts"; + long attempts = await cacheClient.IncrementAsync(cacheKey, 1, timeProvider.GetUtcNow().UtcDateTime.Ceiling(TimeSpan.FromHours(1))); + return attempts > appOptions.OAuthServerOptions.DeviceAuthorizationIpLimit; + } + private string GetOrigin() { return new Uri(appOptions.BaseURL).GetLeftPart(UriPartial.Authority); @@ -160,6 +252,11 @@ private string GetResource(OAuthResourceDefinition resourceDefinition) return OAuthService.CreateResourceUri(GetOrigin(), resourceDefinition); } + private string GetDeviceVerificationUri() + { + return $"{GetOrigin()}/api/v2/oauth/device"; + } + private static IResult OAuthError(string? error, string? description) { return HttpResults.BadRequest(new OAuthErrorResponse diff --git a/src/Exceptionless.Web/Api/Messages/OAuthMessages.cs b/src/Exceptionless.Web/Api/Messages/OAuthMessages.cs index 1e8e5574b1..161ce0433c 100644 --- a/src/Exceptionless.Web/Api/Messages/OAuthMessages.cs +++ b/src/Exceptionless.Web/Api/Messages/OAuthMessages.cs @@ -10,5 +10,10 @@ public record RedirectToAuthorizeBridge; public record CompleteOAuthAuthorization(OAuthAuthorizeForm Form); public record GetOAuthAuthorizeConsent(OAuthAuthorizeForm Form); public record RegisterOAuthClient(OAuthClientRegistrationRequest Request); +public record CreateDeviceAuthorization(OAuthDeviceAuthorizationForm Form); +public record RedirectToDeviceBridge(string? UserCode); +public record GetDeviceConsent(OAuthDeviceConsentForm Form); +public record ApproveDeviceAuthorization(OAuthDeviceAuthorizeForm Form); +public record DenyDeviceAuthorization(OAuthDeviceConsentForm Form); public record IssueOAuthToken(OAuthTokenForm Form); public record RevokeOAuthToken(OAuthRevokeForm Form); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/admin/models.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/admin/models.ts index b4726554da..3f66f4973e 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/admin/models.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/admin/models.ts @@ -97,6 +97,7 @@ export type OAuthApplication = { client_id: string; created_by_user_id: string; created_utc: string; + grant_types: string[]; id: string; is_disabled: boolean; name: string; @@ -109,6 +110,7 @@ export type OAuthApplication = { export type OAuthApplicationRequest = { client_id: string; + grant_types: string[]; is_disabled: boolean; name: string; notes?: null | string; diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/admin/schemas.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/admin/schemas.ts index 9e77203415..4d7030d3a3 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/admin/schemas.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/admin/schemas.ts @@ -1,5 +1,10 @@ import { array, boolean, date, type infer as Infer, object, string } from 'zod'; +const authorizationCodeGrantType = 'authorization_code'; +const deviceCodeGrantType = 'urn:ietf:params:oauth:grant-type:device_code'; +const refreshTokenGrantType = 'refresh_token'; +const offlineAccessScope = 'offline_access'; + export const RunMaintenanceJobSchema = object({ confirmText: string().min(1), organizationId: string().optional(), @@ -10,30 +15,42 @@ export type RunMaintenanceJobFormData = Infer; export const OAuthApplicationSchema = object({ client_id: string().min(3).max(2048), + grant_types: array(string()).min(1, 'Select at least one grant type.'), is_disabled: boolean(), name: string().min(1).max(200), notes: string().max(1000).optional(), - redirect_uris: string() - .min(1, 'Enter at least one redirect URI.') - .refine( - (value) => - value - .split(/\r?\n/) - .map((v) => v.trim()) - .filter(Boolean) - .every((v) => { - try { - const uri = new URL(v); - const isHttps = uri.protocol === 'https:'; - const isLoopbackHttp = - uri.protocol === 'http:' && (uri.hostname === 'localhost' || uri.hostname === '127.0.0.1' || uri.hostname === '[::1]'); - return !uri.hash && (isHttps || isLoopbackHttp); - } catch { - return false; - } - }), - 'Each redirect URI must be HTTPS or loopback HTTP without a fragment.' - ), + redirect_uris: string().refine( + (value) => + !value.trim() || + value + .split(/\r?\n/) + .map((v) => v.trim()) + .filter(Boolean) + .every((v) => { + try { + const uri = new URL(v); + const isHttps = uri.protocol === 'https:'; + const isLoopbackHttp = + uri.protocol === 'http:' && (uri.hostname === 'localhost' || uri.hostname === '127.0.0.1' || uri.hostname === '[::1]'); + return !uri.hash && (isHttps || isLoopbackHttp); + } catch { + return false; + } + }), + 'Each redirect URI must be HTTPS or loopback HTTP without a fragment.' + ), scopes: array(string()).min(1, 'Select at least one scope.') -}); +}) + .refine((value) => value.grant_types.includes(authorizationCodeGrantType) || value.grant_types.includes(deviceCodeGrantType), { + message: 'Select authorization code or device code.', + path: ['grant_types'] + }) + .refine((value) => !value.grant_types.includes(authorizationCodeGrantType) || value.redirect_uris.trim().length > 0, { + message: 'Enter at least one redirect URI for authorization-code clients.', + path: ['redirect_uris'] + }) + .refine((value) => !value.scopes.includes(offlineAccessScope) || value.grant_types.includes(refreshTokenGrantType), { + message: 'Offline access requires the refresh token grant type.', + path: ['scopes'] + }); export type OAuthApplicationFormData = Infer; diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/account/ai-tools/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/account/ai-tools/+page.svelte index 380234a623..68c23fcbb7 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/account/ai-tools/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/account/ai-tools/+page.svelte @@ -164,6 +164,11 @@ {/each} + + Remote SSH and headless sessions can use device code authorization when your AI tool supports it. Open the verification URL in your local + browser, enter the code shown in the terminal, and return to the terminal after approval. + +
Try asking
    diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/system/oauth-applications/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/system/oauth-applications/+page.svelte index fe7eadd419..5a3493d125 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/system/oauth-applications/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/system/oauth-applications/+page.svelte @@ -41,6 +41,16 @@ { description: 'Allows refresh token issuance.', label: 'Offline Access', value: 'offline_access' } ] as const; + const supportedGrantTypes = [ + { description: 'Uses a browser redirect and PKCE challenge.', label: 'Authorization Code', value: 'authorization_code' }, + { + description: 'Uses a user code for SSH, terminal, and headless clients.', + label: 'Device Code', + value: 'urn:ietf:params:oauth:grant-type:device_code' + }, + { description: 'Allows refresh token rotation when offline access is approved.', label: 'Refresh Token', value: 'refresh_token' } + ] as const; + const applicationsQuery = getOAuthApplicationsQuery(); const createApplication = postOAuthApplicationMutation(); const updateApplication = putOAuthApplicationMutation(); @@ -83,6 +93,7 @@ function getFormValues(application: null | OAuthApplication): OAuthApplicationFormData { return { client_id: application?.client_id ?? '', + grant_types: application?.grant_types ?? ['authorization_code', 'refresh_token'], is_disabled: application?.is_disabled ?? false, name: application?.name ?? '', notes: application?.notes ?? '', @@ -94,6 +105,7 @@ function setFormValues(application: null | OAuthApplication) { const values = getFormValues(application); form.setFieldValue('client_id', values.client_id); + form.setFieldValue('grant_types', values.grant_types); form.setFieldValue('is_disabled', values.is_disabled); form.setFieldValue('name', values.name); form.setFieldValue('notes', values.notes); @@ -104,6 +116,7 @@ function toRequest(value: OAuthApplicationFormData): OAuthApplicationRequest { return { client_id: value.client_id.trim(), + grant_types: value.grant_types, is_disabled: value.is_disabled, name: value.name.trim(), notes: value.notes?.trim() || null, @@ -146,6 +159,10 @@ function isScopeChecked(scopes: string[], scope: string) { return scopes.includes(scope); } + + function isGrantTypeChecked(grantTypes: string[], grantType: string) { + return grantTypes.includes(grantType); + }
    @@ -180,6 +197,7 @@ Name Client ID + Grant Types Scopes Status Actions @@ -202,6 +220,13 @@
    + +
    + {#each application.grant_types as grantType (grantType)} + {grantType} + {/each} +
    +
    {#each application.scopes as scope (scope)} @@ -307,7 +332,34 @@ oninput={(e) => field.handleChange(e.currentTarget.value)} aria-invalid={ariaInvalid(field)} /> - One redirect URI per line. + One redirect URI per line. Required only for authorization-code clients. + + + {/snippet} + + + + {#snippet children(field)} + + Grant Types +
    + {#each supportedGrantTypes as grantType (grantType.value)} + {@const checked = isGrantTypeChecked(field.state.value, grantType.value)} +
    + { + const current = field.state.value; + field.handleChange(value ? [...current, grantType.value] : current.filter((g) => g !== grantType.value)); + }} + /> +
    + +

    {grantType.description}

    +
    +
    + {/each} +
    {/snippet} diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(auth)/oauth/device/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(auth)/oauth/device/+page.svelte new file mode 100644 index 0000000000..cfbbd77ee3 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/routes/(auth)/oauth/device/+page.svelte @@ -0,0 +1,451 @@ + + +
    + + + + Authorize device + Approve the Exceptionless access requested by your terminal session. + + + {#if status === 'approved'} + +
    +

    Device authorized

    +

    Return to your terminal to continue.

    +
    +
    + {:else if status === 'denied'} + +
    +

    Device denied

    +

    The requesting terminal session will receive an access denied response.

    +
    +
    + {:else} + +
    { + event.preventDefault(); + void loadConsentDetails(enteredUserCode); + }} + > +
    + +
    + { + enteredUserCode = event.currentTarget.value; + }} + /> + +
    +
    +
    + + {#if consentDetails && status === 'review'} +
    +
    + Signed in as + {#if meQuery.isLoading} +

    Loading account...

    + {:else if meQuery.isError} +

    Unable to load account details.

    + {:else} +

    {accountDisplayName}

    +

    + {meQuery.data?.email_address} +

    + {/if} +
    +
    + Organizations + {#if organizationsQuery.isLoading} +

    Loading organizations...

    + {:else if organizationsQuery.isError} +

    Unable to load organizations.

    + {:else if organizations.length > 0} +
    + {#each organizations as organization (organization.id)} + + {/each} +
    + {:else} +

    This account is not a member of any organizations.

    + {/if} +
    +
    + +
    +
    + Application +

    {applicationDisplayName}

    + {#if applicationClientId !== applicationDisplayName} +

    {applicationClientId}

    + {/if} +
    +
    + Device Code +

    {consentDetails.user_code}

    +
    +
    + Resource +

    {consentDetails.resource}

    +
    +
    + +
    + Scopes +
    + {#each requestedRequiredScopes as scope (scope)} +
    + + + {formatScope(scope)} + Required + + {scope} + +
    + {/each} + {#each requestedOptionalScopes as scope (scope)} + + {/each} +
    + {#if !hasSelectedResourceScope} +

    Select at least one access scope.

    + {/if} + {#if !hasRequiredScopes} +

    Missing required scope: {requiredScopes.map(formatScope).join(', ')}.

    + {/if} +
    + {/if} + + {#if errorMessage} + + {/if} +
    + + {#if consentDetails && status === 'review'} + + + + + {/if} + {/if} +
    +
    diff --git a/src/Exceptionless.Web/Models/Admin/NewOAuthApplication.cs b/src/Exceptionless.Web/Models/Admin/NewOAuthApplication.cs index 147446cfcd..a24d235fe8 100644 --- a/src/Exceptionless.Web/Models/Admin/NewOAuthApplication.cs +++ b/src/Exceptionless.Web/Models/Admin/NewOAuthApplication.cs @@ -12,14 +12,16 @@ public record NewOAuthApplication [MaxLength(200)] public required string Name { get; init; } - [Required] - [Length(1, 20)] - public required string[] RedirectUris { get; init; } + [Length(0, 20)] + public string[]? RedirectUris { get; init; } [Required] [Length(1, 20)] public required string[] Scopes { get; init; } + [Length(1, 3)] + public string[]? GrantTypes { get; init; } + [MaxLength(1000)] public string? Notes { get; init; } diff --git a/src/Exceptionless.Web/Models/Admin/ViewOAuthApplication.cs b/src/Exceptionless.Web/Models/Admin/ViewOAuthApplication.cs index a9b059ed06..ffb8ba03c7 100644 --- a/src/Exceptionless.Web/Models/Admin/ViewOAuthApplication.cs +++ b/src/Exceptionless.Web/Models/Admin/ViewOAuthApplication.cs @@ -9,6 +9,7 @@ public record ViewOAuthApplication public required string Name { get; init; } public required string[] RedirectUris { get; init; } public required string[] Scopes { get; init; } + public required string[] GrantTypes { get; init; } public string? Notes { get; init; } public bool IsDisabled { get; init; } public required string CreatedByUserId { get; init; } @@ -25,6 +26,7 @@ public static ViewOAuthApplication FromApplication(OAuthApplication application) Name = application.Name, RedirectUris = application.RedirectUris, Scopes = application.Scopes, + GrantTypes = application.GrantTypes, Notes = application.Notes, IsDisabled = application.IsDisabled, CreatedByUserId = application.CreatedByUserId, diff --git a/src/Exceptionless.Web/Models/OAuth/OAuthModels.cs b/src/Exceptionless.Web/Models/OAuth/OAuthModels.cs index 14dc6bb595..68df8078a8 100644 --- a/src/Exceptionless.Web/Models/OAuth/OAuthModels.cs +++ b/src/Exceptionless.Web/Models/OAuth/OAuthModels.cs @@ -88,6 +88,9 @@ public sealed record OAuthAuthorizationServerMetadata [JsonPropertyName("token_endpoint")] public required string TokenEndpoint { get; init; } + [JsonPropertyName("device_authorization_endpoint")] + public required string DeviceAuthorizationEndpoint { get; init; } + [JsonPropertyName("registration_endpoint")] public required string RegistrationEndpoint { get; init; } @@ -154,10 +157,64 @@ public sealed record OAuthTokenForm [FromForm(Name = "refresh_token")] public string? RefreshToken { get; init; } + [FromForm(Name = "device_code")] + public string? DeviceCode { get; init; } + [FromForm(Name = "resource")] public string? Resource { get; init; } } +public sealed record OAuthDeviceAuthorizationForm +{ + [FromForm(Name = "client_id")] + public string? ClientId { get; init; } + + [FromForm(Name = "scope")] + public string? Scope { get; init; } + + [FromForm(Name = "resource")] + public string? Resource { get; init; } +} + +public sealed record OAuthDeviceConsentForm +{ + [JsonPropertyName("user_code")] + public string? UserCode { get; init; } +} + +public sealed record OAuthDeviceAuthorizeForm +{ + [JsonPropertyName("user_code")] + public string? UserCode { get; init; } + + [JsonPropertyName("scope")] + public string? Scope { get; init; } + + [JsonPropertyName("organization_ids")] + public string[]? OrganizationIds { get; init; } +} + +public sealed record OAuthDeviceConsentResponse +{ + [JsonPropertyName("client_id")] + public required string ClientId { get; init; } + + [JsonPropertyName("client_name")] + public required string ClientName { get; init; } + + [JsonPropertyName("user_code")] + public required string UserCode { get; init; } + + [JsonPropertyName("resource")] + public required string Resource { get; init; } + + [JsonPropertyName("scopes")] + public required IReadOnlyCollection Scopes { get; init; } + + [JsonPropertyName("required_scopes")] + public required IReadOnlyCollection RequiredScopes { get; init; } +} + public sealed record OAuthRevokeForm { [FromForm(Name = "token")] diff --git a/tests/Exceptionless.Tests/Api/Data/endpoint-manifest.json b/tests/Exceptionless.Tests/Api/Data/endpoint-manifest.json index 65f2578aba..47db1332a0 100644 --- a/tests/Exceptionless.Tests/Api/Data/endpoint-manifest.json +++ b/tests/Exceptionless.Tests/Api/Data/endpoint-manifest.json @@ -958,6 +958,72 @@ "authorizationRoles": [], "authenticationSchemes": [] }, + { + "method": "GET", + "route": "/api/v2/oauth/device", + "displayName": "HTTP: GET api/v2/oauth/device", + "tags": [ + "OAuth" + ], + "allowAnonymous": true, + "authorizationPolicies": [], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "POST", + "route": "/api/v2/oauth/device/authorize", + "displayName": "HTTP: POST api/v2/oauth/device/authorize", + "tags": [ + "OAuth" + ], + "allowAnonymous": false, + "authorizationPolicies": [ + "UserPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "POST", + "route": "/api/v2/oauth/device/consent", + "displayName": "HTTP: POST api/v2/oauth/device/consent", + "tags": [ + "OAuth" + ], + "allowAnonymous": false, + "authorizationPolicies": [ + "UserPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "POST", + "route": "/api/v2/oauth/device/deny", + "displayName": "HTTP: POST api/v2/oauth/device/deny", + "tags": [ + "OAuth" + ], + "allowAnonymous": false, + "authorizationPolicies": [ + "UserPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "POST", + "route": "/api/v2/oauth/device_authorization", + "displayName": "HTTP: POST api/v2/oauth/device_authorization =\u003E CreateDeviceAuthorizationAsync", + "tags": [ + "OAuth" + ], + "allowAnonymous": true, + "authorizationPolicies": [], + "authorizationRoles": [], + "authenticationSchemes": [] + }, { "method": "POST", "route": "/api/v2/oauth/register", diff --git a/tests/Exceptionless.Tests/Api/Data/openapi.json b/tests/Exceptionless.Tests/Api/Data/openapi.json index 68ec2364ce..a0543b9b67 100644 --- a/tests/Exceptionless.Tests/Api/Data/openapi.json +++ b/tests/Exceptionless.Tests/Api/Data/openapi.json @@ -7764,6 +7764,239 @@ } } }, + "/api/v2/oauth/device_authorization": { + "post": { + "tags": [ + "OAuth" + ], + "requestBody": { + "content": { + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/OAuthDeviceAuthorizationForm" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OAuthDeviceAuthorizationResponse" + } + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OAuthErrorResponse" + } + } + } + }, + "429": { + "description": "Too Many Requests", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OAuthErrorResponse" + } + } + } + } + } + } + }, + "/api/v2/oauth/device": { + "get": { + "tags": [ + "OAuth" + ], + "parameters": [ + { + "name": "user_code", + "in": "query", + "schema": { + "type": "string" + } + } + ], + "responses": { + "302": { + "description": "Found" + } + } + } + }, + "/api/v2/oauth/device/consent": { + "post": { + "tags": [ + "OAuth" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OAuthDeviceConsentForm" + } + }, + "application/*\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/OAuthDeviceConsentForm" + } + }, + "application/octet-stream": { + "schema": { + "$ref": "#/components/schemas/OAuthDeviceConsentForm" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/OAuthDeviceConsentForm" + } + }, + "text/plain": { + "schema": { + "$ref": "#/components/schemas/OAuthDeviceConsentForm" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OAuthDeviceConsentResponse" + } + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OAuthErrorResponse" + } + } + } + } + } + } + }, + "/api/v2/oauth/device/authorize": { + "post": { + "tags": [ + "OAuth" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OAuthDeviceAuthorizeForm" + } + }, + "application/*\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/OAuthDeviceAuthorizeForm" + } + }, + "application/octet-stream": { + "schema": { + "$ref": "#/components/schemas/OAuthDeviceAuthorizeForm" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/OAuthDeviceAuthorizeForm" + } + }, + "text/plain": { + "schema": { + "$ref": "#/components/schemas/OAuthDeviceAuthorizeForm" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OAuthErrorResponse" + } + } + } + } + } + } + }, + "/api/v2/oauth/device/deny": { + "post": { + "tags": [ + "OAuth" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OAuthDeviceConsentForm" + } + }, + "application/*\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/OAuthDeviceConsentForm" + } + }, + "application/octet-stream": { + "schema": { + "$ref": "#/components/schemas/OAuthDeviceConsentForm" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/OAuthDeviceConsentForm" + } + }, + "text/plain": { + "schema": { + "$ref": "#/components/schemas/OAuthDeviceConsentForm" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OAuthErrorResponse" + } + } + } + } + } + } + }, "/api/v2/oauth/token": { "post": { "tags": [ @@ -11396,6 +11629,7 @@ "issuer", "authorization_endpoint", "token_endpoint", + "device_authorization_endpoint", "registration_endpoint", "revocation_endpoint", "grant_types_supported", @@ -11417,6 +11651,9 @@ "token_endpoint": { "type": "string" }, + "device_authorization_endpoint": { + "type": "string" + }, "registration_endpoint": { "type": "string" }, @@ -11662,6 +11899,136 @@ } } }, + "OAuthDeviceAuthorizationForm": { + "type": "object", + "properties": { + "client_id": { + "type": [ + "null", + "string" + ] + }, + "scope": { + "type": [ + "null", + "string" + ] + }, + "resource": { + "type": [ + "null", + "string" + ] + } + } + }, + "OAuthDeviceAuthorizationResponse": { + "required": [ + "device_code", + "user_code", + "verification_uri", + "verification_uri_complete", + "expires_in", + "interval" + ], + "type": "object", + "properties": { + "device_code": { + "type": "string" + }, + "user_code": { + "type": "string" + }, + "verification_uri": { + "type": "string" + }, + "verification_uri_complete": { + "type": "string" + }, + "expires_in": { + "type": "integer", + "format": "int32" + }, + "interval": { + "type": "integer", + "format": "int32" + } + } + }, + "OAuthDeviceAuthorizeForm": { + "type": "object", + "properties": { + "user_code": { + "type": [ + "null", + "string" + ] + }, + "scope": { + "type": [ + "null", + "string" + ] + }, + "organization_ids": { + "type": [ + "null", + "array" + ], + "items": { + "type": "string" + } + } + } + }, + "OAuthDeviceConsentForm": { + "type": "object", + "properties": { + "user_code": { + "type": [ + "null", + "string" + ] + } + } + }, + "OAuthDeviceConsentResponse": { + "required": [ + "client_id", + "client_name", + "user_code", + "resource", + "scopes", + "required_scopes" + ], + "type": "object", + "properties": { + "client_id": { + "type": "string" + }, + "client_name": { + "type": "string" + }, + "user_code": { + "type": "string" + }, + "resource": { + "type": "string" + }, + "scopes": { + "type": "array", + "items": { + "type": "string" + } + }, + "required_scopes": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, "OAuthErrorResponse": { "required": [ "error" @@ -11771,6 +12138,12 @@ "string" ] }, + "device_code": { + "type": [ + "null", + "string" + ] + }, "resource": { "type": [ "null", diff --git a/tests/Exceptionless.Tests/Api/Endpoints/OAuthApplicationEndpointTests.cs b/tests/Exceptionless.Tests/Api/Endpoints/OAuthApplicationEndpointTests.cs index e0658b5eb0..bd135416be 100644 --- a/tests/Exceptionless.Tests/Api/Endpoints/OAuthApplicationEndpointTests.cs +++ b/tests/Exceptionless.Tests/Api/Endpoints/OAuthApplicationEndpointTests.cs @@ -1,5 +1,6 @@ using Exceptionless.Core.Authorization; using Exceptionless.Core.Repositories; +using Exceptionless.Core.Services; using Exceptionless.Core.Utility; using Exceptionless.Tests.Extensions; using Exceptionless.Web.Models.Admin; @@ -41,6 +42,7 @@ public async Task CreateAsync_AsGlobalAdmin_CreatesOAuthApplication() Assert.NotNull(created.Id); Assert.Equal("chatgpt-dev", created.ClientId); Assert.Equal("ChatGPT Dev", created.Name); + Assert.Equal([OAuthGrantTypes.AuthorizationCode, OAuthGrantTypes.RefreshToken], created.GrantTypes); Assert.Equal(["mcp:read", "events:read"], created.Scopes); Assert.Equal("Dev OAuth app", created.Notes); Assert.False(created.IsDisabled); @@ -84,6 +86,7 @@ public async Task UpdateAsync_AsGlobalAdmin_UpdatesOAuthApplication() { ClientId = "chatgpt-production", Name = "ChatGPT Production", + GrantTypes = [OAuthGrantTypes.AuthorizationCode], RedirectUris = ["https://chat.openai.com/aip/g-production/oauth/callback"], Scopes = [AuthorizationRoles.McpRead, AuthorizationRoles.ProjectsRead], Notes = "Production client", @@ -95,6 +98,7 @@ public async Task UpdateAsync_AsGlobalAdmin_UpdatesOAuthApplication() Assert.Equal(created.Id, updated.Id); Assert.Equal("chatgpt-production", updated.ClientId); Assert.Equal("ChatGPT Production", updated.Name); + Assert.Equal([OAuthGrantTypes.AuthorizationCode], updated.GrantTypes); Assert.Equal(["mcp:read", "projects:read"], updated.Scopes); Assert.True(updated.IsDisabled); @@ -159,6 +163,48 @@ public async Task CreateAsync_InsecureRedirectUri_ReturnsUnprocessableEntity() Assert.Contains("redirect_uris", problem.Errors.Keys); } + [Fact] + public async Task CreateAsync_DeviceOnlyClientWithoutRedirectUri_CreatesOAuthApplication() + { + var created = await CreateApplicationAsync(new NewOAuthApplication + { + ClientId = "device-client", + Name = "Device Client", + GrantTypes = [OAuthGrantTypes.DeviceCode, OAuthGrantTypes.RefreshToken], + RedirectUris = [], + Scopes = [AuthorizationRoles.McpRead, AuthorizationRoles.OfflineAccess], + Notes = null, + IsDisabled = false + }); + + Assert.NotNull(created); + Assert.Empty(created.RedirectUris); + Assert.Equal([OAuthGrantTypes.DeviceCode, OAuthGrantTypes.RefreshToken], created.GrantTypes); + } + + [Fact] + public async Task CreateAsync_AuthorizationCodeClientWithoutRedirectUri_ReturnsUnprocessableEntity() + { + var problem = await SendRequestAsAsync(r => r + .Post() + .AsGlobalAdminUser() + .AppendPaths("admin", "oauth-applications") + .Content(new NewOAuthApplication + { + ClientId = "bad-redirect-client", + Name = "Bad Redirect Client", + GrantTypes = [OAuthGrantTypes.AuthorizationCode], + RedirectUris = [], + Scopes = [AuthorizationRoles.McpRead], + Notes = null, + IsDisabled = false + }) + .StatusCodeShouldBeUnprocessableEntity()); + + Assert.NotNull(problem); + Assert.Contains("redirect_uris", problem.Errors.Keys); + } + [Fact] public Task GetAllAsync_AsOrganizationUser_ReturnsForbidden() { @@ -184,6 +230,7 @@ private static NewOAuthApplication CreateModel(string clientId, string name) { ClientId = clientId, Name = name, + GrantTypes = [OAuthGrantTypes.AuthorizationCode, OAuthGrantTypes.RefreshToken], RedirectUris = ["https://chat.openai.com/aip/g-test/oauth/callback"], Scopes = [AuthorizationRoles.McpRead, AuthorizationRoles.ProjectsRead], Notes = null, diff --git a/tests/Exceptionless.Tests/Api/Endpoints/OAuthEndpointTests.cs b/tests/Exceptionless.Tests/Api/Endpoints/OAuthEndpointTests.cs index 9a39cc8f4e..c745483a7b 100644 --- a/tests/Exceptionless.Tests/Api/Endpoints/OAuthEndpointTests.cs +++ b/tests/Exceptionless.Tests/Api/Endpoints/OAuthEndpointTests.cs @@ -25,6 +25,7 @@ namespace Exceptionless.Tests.Api.Endpoints; public sealed class OAuthEndpointTests : IntegrationTestsBase { private const string ClientId = "test-oauth-client"; + private const string DeviceClientId = "test-device-oauth-client"; private const string RedirectUri = "http://localhost/callback"; private const string MetadataClientId = "https://oauth.example/client.json"; private const string MetadataNoScopeClientId = "https://oauth.example/no-scope-client.json"; @@ -35,6 +36,7 @@ public sealed class OAuthEndpointTests : IntegrationTestsBase private const string RestApiResource = "http://localhost:7110/api/v2"; private const string PkceVerifier = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._~"; private const string WrongPkceVerifier = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"; + private static readonly string DefaultDeviceAuthorizationScope = $"{AuthorizationRoles.McpRead} {AuthorizationRoles.ProjectsRead} {AuthorizationRoles.StacksRead} {AuthorizationRoles.EventsRead} {AuthorizationRoles.OfflineAccess}"; private readonly IOAuthApplicationRepository _oauthApplicationRepository; private readonly IOAuthTokenRepository _oauthTokenRepository; @@ -59,6 +61,7 @@ protected override async Task ResetDataAsync() var service = GetService(); await service.CreateDataAsync(); await CreateStoredOAuthApplicationAsync(ClientId, RedirectUri); + await CreateStoredOAuthApplicationAsync(DeviceClientId, null, grantTypes: [OAuthGrantTypes.DeviceCode, OAuthGrantTypes.RefreshToken]); } [Fact] @@ -74,8 +77,10 @@ public async Task GetAuthorizationServerMetadataAsync_ReturnsOAuthMetadata() Assert.Equal("http://localhost:7110", metadata.Issuer); Assert.Equal("http://localhost:7110/api/v2/oauth/authorize", metadata.AuthorizationEndpoint); Assert.Equal("http://localhost:7110/api/v2/oauth/token", metadata.TokenEndpoint); + Assert.Equal("http://localhost:7110/api/v2/oauth/device_authorization", metadata.DeviceAuthorizationEndpoint); Assert.Equal("http://localhost:7110/api/v2/oauth/register", metadata.RegistrationEndpoint); Assert.Contains(OAuthGrantTypes.AuthorizationCode, metadata.GrantTypesSupported); + Assert.Contains(OAuthGrantTypes.DeviceCode, metadata.GrantTypesSupported); Assert.Contains(OAuthService.CodeChallengeMethod, metadata.CodeChallengeMethodsSupported); Assert.Contains(AuthorizationRoles.McpRead, metadata.ScopesSupported); Assert.Contains(AuthorizationRoles.StacksWrite, metadata.ScopesSupported); @@ -104,6 +109,7 @@ public async Task RegisterAsync_ValidDynamicClient_ReturnsClientAndPersistsAppli Assert.Equal("Codex", registration.ClientName); Assert.Contains("http://127.0.0.1:49152/callback", registration.RedirectUris); Assert.Contains(OAuthGrantTypes.AuthorizationCode, registration.GrantTypes); + Assert.Contains(OAuthGrantTypes.RefreshToken, registration.GrantTypes); Assert.Equal("none", registration.TokenEndpointAuthMethod); Assert.Contains(AuthorizationRoles.McpRead, registration.Scope); @@ -115,7 +121,53 @@ public async Task RegisterAsync_ValidDynamicClient_ReturnsClientAndPersistsAppli } [Fact] - public async Task RegisterAsync_WithoutScope_DefaultsToReadOnlyScopes() + public async Task RegisterAsync_DeviceOnlyClientWithoutRedirectUri_ReturnsClient() + { + using var client = CreateHttpClient(); + + var response = await client.PostAsJsonAsync("oauth/register", new OAuthClientRegistrationRequest + { + ClientName = "SSH MCP Client", + GrantTypes = [OAuthGrantTypes.DeviceCode, OAuthGrantTypes.RefreshToken], + Scope = $"{AuthorizationRoles.McpRead} {AuthorizationRoles.OfflineAccess}", + TokenEndpointAuthMethod = "none" + }, TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.Created, response.StatusCode); + var registration = await DeserializeResponseAsync(response); + Assert.NotNull(registration); + Assert.Empty(registration.RedirectUris); + Assert.Empty(registration.ResponseTypes); + Assert.Contains(OAuthGrantTypes.DeviceCode, registration.GrantTypes); + Assert.Contains(OAuthGrantTypes.RefreshToken, registration.GrantTypes); + + var application = await _oauthApplicationRepository.GetByClientIdAsync(registration.ClientId, o => o.ImmediateConsistency()); + Assert.NotNull(application); + Assert.Empty(application.RedirectUris); + Assert.Contains(OAuthGrantTypes.DeviceCode, application.GrantTypes); + } + + [Fact] + public async Task RegisterAsync_AuthorizationCodeClientWithoutRedirectUri_ReturnsBadRequest() + { + using var client = CreateHttpClient(); + + var response = await client.PostAsJsonAsync("oauth/register", new OAuthClientRegistrationRequest + { + ClientName = "Bad Authorization Code Client", + GrantTypes = [OAuthGrantTypes.AuthorizationCode], + Scope = AuthorizationRoles.McpRead, + TokenEndpointAuthMethod = "none" + }, TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + var error = await response.DeserializeAsync(ensureSuccess: false); + Assert.NotNull(error); + Assert.Equal("invalid_redirect_uri", error.Error); + } + + [Fact] + public async Task RegisterAsync_WithoutScope_DefaultsToScopesAllowedByGrantTypes() { using var client = CreateHttpClient(); @@ -136,7 +188,7 @@ public async Task RegisterAsync_WithoutScope_DefaultsToReadOnlyScopes() Assert.Contains(AuthorizationRoles.StacksRead, registration.Scope); Assert.Contains(AuthorizationRoles.EventsRead, registration.Scope); Assert.DoesNotContain(AuthorizationRoles.StacksWrite, registration.Scope); - Assert.Contains(AuthorizationRoles.OfflineAccess, registration.Scope); + Assert.DoesNotContain(AuthorizationRoles.OfflineAccess, registration.Scope); var application = await _oauthApplicationRepository.GetByClientIdAsync(registration.ClientId, o => o.ImmediateConsistency()); Assert.NotNull(application); @@ -145,7 +197,7 @@ public async Task RegisterAsync_WithoutScope_DefaultsToReadOnlyScopes() Assert.Contains(AuthorizationRoles.StacksRead, application.Scopes); Assert.Contains(AuthorizationRoles.EventsRead, application.Scopes); Assert.DoesNotContain(AuthorizationRoles.StacksWrite, application.Scopes); - Assert.Contains(AuthorizationRoles.OfflineAccess, application.Scopes); + Assert.DoesNotContain(AuthorizationRoles.OfflineAccess, application.Scopes); } [Fact] @@ -653,6 +705,255 @@ public async Task CompleteAuthorizeAsync_ClientMetadataDocumentMismatch_ReturnsB Assert.Null(application); } + [Fact] + public async Task DeviceAuthorizationAsync_ValidRequest_ReturnsDeviceAuthorizationResponse() + { + var authorization = await StartDeviceAuthorizationAsync(); + + Assert.NotNull(authorization); + Assert.Equal(OAuthService.OAuthTokenLength, authorization.DeviceCode.Length); + Assert.Matches("^[A-Z2-9]{4}-[A-Z2-9]{4}$", authorization.UserCode); + Assert.Equal("http://localhost:7110/api/v2/oauth/device", authorization.VerificationUri); + Assert.Contains($"user_code={Uri.EscapeDataString(authorization.UserCode)}", authorization.VerificationUriComplete); + Assert.Equal(900, authorization.ExpiresIn); + Assert.Equal(5, authorization.Interval); + } + + [Fact] + public async Task DeviceAuthorizationAsync_WithoutScope_UsesClientDefaultScopes() + { + using var client = CreateHttpClient(); + + var authorizationResponse = await client.PostAsync("oauth/device_authorization", CreateDeviceAuthorizationContent(scope: null), TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.OK, authorizationResponse.StatusCode); + var authorization = await DeserializeResponseAsync(authorizationResponse); + Assert.NotNull(authorization); + + using var consentRequest = new HttpRequestMessage(HttpMethod.Post, "oauth/device/consent") + { + Content = JsonContent.Create(new OAuthDeviceConsentForm + { + UserCode = authorization.UserCode + }) + }; + consentRequest.Headers.Authorization = new AuthenticationHeaderValue("Basic", Convert.ToBase64String(Encoding.UTF8.GetBytes($"{SampleDataService.TEST_USER_EMAIL}:{SampleDataService.TEST_USER_PASSWORD}"))); + + var consentResponse = await client.SendAsync(consentRequest, TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.OK, consentResponse.StatusCode); + var consent = await DeserializeResponseAsync(consentResponse); + Assert.NotNull(consent); + Assert.Contains(AuthorizationRoles.McpRead, consent.Scopes); + Assert.Contains(AuthorizationRoles.ProjectsRead, consent.Scopes); + Assert.Contains(AuthorizationRoles.StacksRead, consent.Scopes); + Assert.Contains(AuthorizationRoles.EventsRead, consent.Scopes); + Assert.Contains(AuthorizationRoles.OfflineAccess, consent.Scopes); + Assert.DoesNotContain(AuthorizationRoles.StacksWrite, consent.Scopes); + } + + [Fact] + public async Task DeviceAuthorizationAsync_UnknownClient_ReturnsBadRequest() + { + using var client = CreateHttpClient(); + + var response = await client.PostAsync("oauth/device_authorization", CreateDeviceAuthorizationContent(clientId: "unknown-device-client"), TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + var error = await response.DeserializeAsync(ensureSuccess: false); + Assert.NotNull(error); + Assert.Equal("invalid_client", error.Error); + } + + [Fact] + public async Task DeviceAuthorizationAsync_InvalidScope_ReturnsBadRequest() + { + using var client = CreateHttpClient(); + + var response = await client.PostAsync("oauth/device_authorization", CreateDeviceAuthorizationContent(scope: AuthorizationRoles.StacksWrite), TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + var error = await response.DeserializeAsync(ensureSuccess: false); + Assert.NotNull(error); + Assert.Equal("invalid_scope", error.Error); + } + + [Fact] + public async Task DeviceAuthorizationAsync_InvalidResource_ReturnsBadRequest() + { + using var client = CreateHttpClient(); + + var response = await client.PostAsync("oauth/device_authorization", CreateDeviceAuthorizationContent(resource: "http://localhost:7110/not-supported"), TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + var error = await response.DeserializeAsync(ensureSuccess: false); + Assert.NotNull(error); + Assert.Equal("invalid_target", error.Error); + } + + [Fact] + public async Task DeviceAuthorizationAsync_TooManyAttempts_ReturnsTooManyRequests() + { + using var client = CreateHttpClient(); + + for (int i = 0; i < 120; i++) + { + var allowedResponse = await client.PostAsync("oauth/device_authorization", CreateDeviceAuthorizationContent(scope: $"{AuthorizationRoles.McpRead} {AuthorizationRoles.OfflineAccess}"), TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.OK, allowedResponse.StatusCode); + } + + var limitedResponse = await client.PostAsync("oauth/device_authorization", CreateDeviceAuthorizationContent(scope: $"{AuthorizationRoles.McpRead} {AuthorizationRoles.OfflineAccess}"), TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.TooManyRequests, limitedResponse.StatusCode); + var error = await limitedResponse.DeserializeAsync(ensureSuccess: false); + Assert.NotNull(error); + Assert.Equal("temporarily_unavailable", error.Error); + } + + [Fact] + public async Task TokenAsync_DeviceCodePendingThenFastPoll_ReturnsPendingThenSlowDown() + { + var authorization = await StartDeviceAuthorizationAsync(); + using var client = CreateHttpClient(); + + try + { + var pendingResponse = await client.PostAsync("oauth/token", CreateDeviceTokenContent(authorization.DeviceCode), TestContext.Current.CancellationToken); + var slowDownResponse = await client.PostAsync("oauth/token", CreateDeviceTokenContent(authorization.DeviceCode), TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.BadRequest, pendingResponse.StatusCode); + var pendingError = await pendingResponse.DeserializeAsync(ensureSuccess: false); + Assert.NotNull(pendingError); + Assert.Equal("authorization_pending", pendingError.Error); + + Assert.Equal(HttpStatusCode.BadRequest, slowDownResponse.StatusCode); + var slowDownError = await slowDownResponse.DeserializeAsync(ensureSuccess: false); + Assert.NotNull(slowDownError); + Assert.Equal("slow_down", slowDownError.Error); + + TimeProvider.Advance(TimeSpan.FromSeconds(5)); + var backoffResponse = await client.PostAsync("oauth/token", CreateDeviceTokenContent(authorization.DeviceCode), TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.BadRequest, backoffResponse.StatusCode); + var backoffError = await backoffResponse.DeserializeAsync(ensureSuccess: false); + Assert.NotNull(backoffError); + Assert.Equal("slow_down", backoffError.Error); + } + finally + { + TimeProvider.Restore(); + } + } + + [Fact] + public async Task DeviceAuthorizationAsync_ConcurrentApproveAndDeny_OnlyOneSucceeds() + { + var authorization = await StartDeviceAuthorizationAsync(); + var oauthService = GetService(); + + var results = await Task.WhenAll( + oauthService.ApproveDeviceAuthorizationAsync(new OAuthDeviceApprovalRequest + { + UserCode = authorization.UserCode, + Scope = DefaultDeviceAuthorizationScope, + OrganizationIds = [TestConstants.OrganizationId] + }, TestConstants.UserId), + oauthService.DenyDeviceAuthorizationAsync(authorization.UserCode)); + + Assert.Single(results, result => result.IsSuccess); + } + + [Fact] + public async Task TokenAsync_ApprovedDeviceCode_ReturnsOAuthTokens() + { + var authorization = await StartDeviceAuthorizationAsync(); + await ApproveDeviceAuthorizationAsync(authorization.UserCode, scope: $"{AuthorizationRoles.McpRead} {AuthorizationRoles.OfflineAccess}"); + using var client = CreateHttpClient(); + + var response = await client.PostAsync("oauth/token", CreateDeviceTokenContent(authorization.DeviceCode), TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var token = await DeserializeResponseAsync(response); + Assert.NotNull(token); + Assert.Equal(Resource, token.Resource); + Assert.NotNull(token.RefreshToken); + Assert.Contains(AuthorizationRoles.McpRead, token.Scope); + Assert.Contains(AuthorizationRoles.OfflineAccess, token.Scope); + + var storedToken = await GetStoredOAuthTokenAsync(token.AccessToken); + Assert.NotNull(storedToken); + Assert.Equal(DeviceClientId, storedToken.ClientId); + Assert.Equal([TestConstants.OrganizationId], storedToken.OrganizationIds); + } + + [Fact] + public async Task TokenAsync_ConsumedDeviceCode_ReturnsExpiredToken() + { + var authorization = await StartDeviceAuthorizationAsync(); + await ApproveDeviceAuthorizationAsync(authorization.UserCode, scope: $"{AuthorizationRoles.McpRead} {AuthorizationRoles.OfflineAccess}"); + using var client = CreateHttpClient(); + + var firstResponse = await client.PostAsync("oauth/token", CreateDeviceTokenContent(authorization.DeviceCode), TestContext.Current.CancellationToken); + var secondResponse = await client.PostAsync("oauth/token", CreateDeviceTokenContent(authorization.DeviceCode), TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.OK, firstResponse.StatusCode); + Assert.Equal(HttpStatusCode.BadRequest, secondResponse.StatusCode); + var error = await secondResponse.DeserializeAsync(ensureSuccess: false); + Assert.NotNull(error); + Assert.Equal("expired_token", error.Error); + } + + [Fact] + public async Task TokenAsync_ApprovedDeviceCodeConcurrently_ReturnsSingleToken() + { + var authorization = await StartDeviceAuthorizationAsync(); + await ApproveDeviceAuthorizationAsync(authorization.UserCode, scope: $"{AuthorizationRoles.McpRead} {AuthorizationRoles.OfflineAccess}"); + using var client = CreateHttpClient(); + + var responses = await Task.WhenAll(Enumerable.Range(0, 8).Select(_ => + client.PostAsync("oauth/token", CreateDeviceTokenContent(authorization.DeviceCode), TestContext.Current.CancellationToken))); + + Assert.Equal(1, responses.Count(response => response.StatusCode == HttpStatusCode.OK)); + Assert.All(responses.Where(response => response.StatusCode != HttpStatusCode.OK), response => Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode)); + } + + [Fact] + public async Task TokenAsync_DeniedDeviceCode_ReturnsAccessDenied() + { + var authorization = await StartDeviceAuthorizationAsync(); + await DenyDeviceAuthorizationAsync(authorization.UserCode); + using var client = CreateHttpClient(); + + var response = await client.PostAsync("oauth/token", CreateDeviceTokenContent(authorization.DeviceCode), TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + var error = await response.DeserializeAsync(ensureSuccess: false); + Assert.NotNull(error); + Assert.Equal("access_denied", error.Error); + } + + [Fact] + public async Task TokenAsync_ExpiredDeviceCode_ReturnsExpiredToken() + { + var authorization = await StartDeviceAuthorizationAsync(); + try + { + TimeProvider.Advance(TimeSpan.FromMinutes(16)); + using var client = CreateHttpClient(); + + var response = await client.PostAsync("oauth/token", CreateDeviceTokenContent(authorization.DeviceCode), TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + var error = await response.DeserializeAsync(ensureSuccess: false); + Assert.NotNull(error); + Assert.Equal("expired_token", error.Error); + } + finally + { + TimeProvider.Restore(); + } + } + [Fact] public async Task TokenAsync_ValidAuthorizationCode_ReturnsOAuthTokens() { @@ -977,9 +1278,45 @@ public async Task TokenAsync_RefreshToken_RotatesRefreshToken() Assert.NotNull(spentToken); Assert.NotNull(refreshedStoredToken); Assert.True(spentToken.IsDisabled); - Assert.True(refreshedStoredToken.IsDisabled); - Assert.Null(spentToken.RefreshTokenHash); - Assert.Null(refreshedStoredToken.RefreshTokenHash); + Assert.False(refreshedStoredToken.IsDisabled); + Assert.Equal(OAuthService.CreateTokenHash(token.RefreshToken), spentToken.RefreshTokenHash); + Assert.Equal(OAuthService.CreateTokenHash(refreshedToken.RefreshToken), refreshedStoredToken.RefreshTokenHash); + } + + [Fact] + public async Task TokenAsync_SpentRefreshTokenReplayAfterGracePeriod_RevokesGrantFamily() + { + var token = await IssueTokenAsync(); + Assert.NotNull(token.RefreshToken); + using var client = CreateHttpClient(); + + var refreshResponse = await client.PostAsync("oauth/token", CreateRefreshTokenContent(token.RefreshToken), TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.OK, refreshResponse.StatusCode); + var refreshedToken = await DeserializeResponseAsync(refreshResponse); + Assert.NotNull(refreshedToken); + + try + { + TimeProvider.Advance(TimeSpan.FromMinutes(3)); + + var replayResponse = await client.PostAsync("oauth/token", CreateRefreshTokenContent(token.RefreshToken), TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.BadRequest, replayResponse.StatusCode); + + var spentToken = await GetStoredOAuthTokenAsync(token.AccessToken); + var refreshedStoredToken = await GetStoredOAuthTokenAsync(refreshedToken.AccessToken); + Assert.NotNull(spentToken); + Assert.NotNull(refreshedStoredToken); + Assert.True(spentToken.IsDisabled); + Assert.True(refreshedStoredToken.IsDisabled); + Assert.Null(spentToken.RefreshTokenHash); + Assert.Null(refreshedStoredToken.RefreshTokenHash); + } + finally + { + TimeProvider.Restore(); + } } [Fact] @@ -1147,6 +1484,13 @@ public async Task TokenAsync_ConcurrentRefreshTokenUse_AllowsOnlyOneRefresh() Assert.Contains(responses, r => r.StatusCode == HttpStatusCode.OK); Assert.Contains(responses, r => r.StatusCode == HttpStatusCode.BadRequest); + var refreshedToken = await DeserializeResponseAsync(Assert.Single(responses, r => r.StatusCode == HttpStatusCode.OK)); + Assert.NotNull(refreshedToken); + + var refreshedStoredToken = await GetStoredOAuthTokenAsync(refreshedToken.AccessToken); + Assert.NotNull(refreshedStoredToken); + Assert.False(refreshedStoredToken.IsDisabled); + Assert.NotNull(refreshedStoredToken.RefreshTokenHash); } [Fact] @@ -1265,6 +1609,50 @@ private async Task RemoveTestUserFromOrganizationAsync(string organizationId) return results.Documents.FirstOrDefault(); } + private async Task StartDeviceAuthorizationAsync(string clientId = DeviceClientId, string? resource = Resource, string? scope = null) + { + using var client = CreateHttpClient(); + var response = await client.PostAsync("oauth/device_authorization", CreateDeviceAuthorizationContent(clientId, resource, scope ?? DefaultDeviceAuthorizationScope), TestContext.Current.CancellationToken); + response.EnsureSuccessStatusCode(); + var authorization = await DeserializeResponseAsync(response); + Assert.NotNull(authorization); + return authorization; + } + + private async Task ApproveDeviceAuthorizationAsync(string userCode, string? scope = null, IReadOnlyCollection? organizationIds = null) + { + using var client = CreateHttpClient(); + using var request = new HttpRequestMessage(HttpMethod.Post, "oauth/device/authorize") + { + Content = JsonContent.Create(new OAuthDeviceAuthorizeForm + { + UserCode = userCode, + Scope = scope ?? $"{AuthorizationRoles.McpRead} {AuthorizationRoles.ProjectsRead} {AuthorizationRoles.StacksRead} {AuthorizationRoles.EventsRead} {AuthorizationRoles.OfflineAccess}", + OrganizationIds = organizationIds?.ToArray() ?? [TestConstants.OrganizationId] + }) + }; + request.Headers.Authorization = new AuthenticationHeaderValue("Basic", Convert.ToBase64String(Encoding.UTF8.GetBytes($"{SampleDataService.TEST_USER_EMAIL}:{SampleDataService.TEST_USER_PASSWORD}"))); + + var response = await client.SendAsync(request, TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + private async Task DenyDeviceAuthorizationAsync(string userCode) + { + using var client = CreateHttpClient(); + using var request = new HttpRequestMessage(HttpMethod.Post, "oauth/device/deny") + { + Content = JsonContent.Create(new OAuthDeviceConsentForm + { + UserCode = userCode + }) + }; + request.Headers.Authorization = new AuthenticationHeaderValue("Basic", Convert.ToBase64String(Encoding.UTF8.GetBytes($"{SampleDataService.TEST_USER_EMAIL}:{SampleDataService.TEST_USER_PASSWORD}"))); + + var response = await client.SendAsync(request, TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + private async Task IssueTokenAsync(string clientId = ClientId, string redirectUri = RedirectUri, string? resource = Resource, string? scope = null, IReadOnlyCollection? organizationIds = null) { string verifier = PkceVerifier; @@ -1294,7 +1682,7 @@ private async Task CreateAuthorizationCodeAsync(string verifier, string return code.ToString(); } - private async Task CreateStoredOAuthApplicationAsync(string clientId, string redirectUri, bool isDisabled = false, string? name = null) + private async Task CreateStoredOAuthApplicationAsync(string clientId, string? redirectUri, bool isDisabled = false, string? name = null, IReadOnlyCollection? grantTypes = null) { var utcNow = TimeProvider.GetUtcNow().UtcDateTime; var application = new OAuthApplication @@ -1302,8 +1690,9 @@ private async Task CreateStoredOAuthApplicationAsync(string cl Id = ObjectId.GenerateNewId().ToString(), ClientId = clientId, Name = name ?? clientId, - RedirectUris = [redirectUri], + RedirectUris = String.IsNullOrWhiteSpace(redirectUri) ? [] : [redirectUri], Scopes = [AuthorizationRoles.McpRead, AuthorizationRoles.ProjectsRead, AuthorizationRoles.StacksRead, AuthorizationRoles.EventsRead, AuthorizationRoles.OfflineAccess], + GrantTypes = grantTypes?.ToArray() ?? [OAuthGrantTypes.AuthorizationCode, OAuthGrantTypes.RefreshToken], Notes = null, IsDisabled = isDisabled, CreatedByUserId = TestConstants.UserId, @@ -1354,6 +1743,30 @@ private static FormUrlEncodedContent CreateRefreshTokenContent(string? refreshTo }); } + private static FormUrlEncodedContent CreateDeviceAuthorizationContent(string clientId = DeviceClientId, string? resource = Resource, string? scope = null) + { + var form = new Dictionary + { + ["client_id"] = clientId, + ["resource"] = resource + }; + + if (scope is not null) + form["scope"] = scope; + + return new FormUrlEncodedContent(form); + } + + private static FormUrlEncodedContent CreateDeviceTokenContent(string deviceCode, string clientId = DeviceClientId) + { + return new FormUrlEncodedContent(new Dictionary + { + ["grant_type"] = OAuthGrantTypes.DeviceCode, + ["client_id"] = clientId, + ["device_code"] = deviceCode + }); + } + private static FormUrlEncodedContent CreateTokenExchangeContent(string code, string verifier, string redirectUri = RedirectUri, string? resource = Resource, string clientId = ClientId) { var form = new Dictionary @@ -1428,7 +1841,7 @@ private sealed class FakeOAuthClientMetadataService : IOAuthClientMetadataServic ClientId = MetadataNoScopeClientId, ClientName = "No Scope AI Client", RedirectUris = [MetadataRedirectUri], - GrantTypes = [OAuthGrantTypes.AuthorizationCode], + GrantTypes = [OAuthGrantTypes.AuthorizationCode, OAuthGrantTypes.RefreshToken], ResponseTypes = ["code"], TokenEndpointAuthMethod = "none" }, @@ -1437,7 +1850,7 @@ private sealed class FakeOAuthClientMetadataService : IOAuthClientMetadataServic ClientId = MetadataClientId, ClientName = "Example AI Client", RedirectUris = [MetadataRedirectUri], - GrantTypes = [OAuthGrantTypes.AuthorizationCode], + GrantTypes = [OAuthGrantTypes.AuthorizationCode, OAuthGrantTypes.RefreshToken], ResponseTypes = ["code"], Scope = String.Join(' ', OAuthService.SupportedScopes), TokenEndpointAuthMethod = "none" @@ -1457,7 +1870,7 @@ private sealed class FakeOAuthClientMetadataService : IOAuthClientMetadataServic ClientId = "https://oauth.example/other-client.json", ClientName = "Mismatched AI Client", RedirectUris = [MetadataRedirectUri], - GrantTypes = [OAuthGrantTypes.AuthorizationCode], + GrantTypes = [OAuthGrantTypes.AuthorizationCode, OAuthGrantTypes.RefreshToken], ResponseTypes = ["code"], Scope = String.Join(' ', OAuthService.SupportedScopes), TokenEndpointAuthMethod = "none" diff --git a/tests/http/oauth.http b/tests/http/oauth.http index 7f226dbf0a..48f919fee3 100644 --- a/tests/http/oauth.http +++ b/tests/http/oauth.http @@ -41,6 +41,29 @@ Content-Type: application/json "scope": "mcp:read projects:read stacks:read events:read offline_access", "token_endpoint_auth_method": "none" } + +### Dynamic Device Client Registration +# @name registerDeviceOAuthClient +POST {{apiUrl}}/oauth/register +Content-Type: application/json + +{ + "client_name": "Codex SSH Dev", + "grant_types": [ + "urn:ietf:params:oauth:grant-type:device_code", + "refresh_token" + ], + "scope": "mcp:read projects:read stacks:read events:read offline_access", + "token_endpoint_auth_method": "none" +} + +### Start Device Authorization +# @name startDeviceAuthorization +POST {{apiUrl}}/oauth/device_authorization +Content-Type: application/x-www-form-urlencoded + +client_id={{registerDeviceOAuthClient.response.body.$.client_id}}&scope=mcp:read%20projects:read%20stacks:read%20events:read%20offline_access&resource={{resource}} + ### Login # @name login POST {{apiUrl}}/auth/login @@ -51,6 +74,34 @@ Content-Type: application/json "password": "{{password}}" } +### Preview Device Consent +POST {{apiUrl}}/oauth/device/consent +Authorization: Bearer {{login.response.body.token}} +Content-Type: application/json + +{ + "user_code": "{{startDeviceAuthorization.response.body.$.user_code}}" +} + +### Approve Device Authorization +POST {{apiUrl}}/oauth/device/authorize +Authorization: Bearer {{login.response.body.token}} +Content-Type: application/json + +{ + "user_code": "{{startDeviceAuthorization.response.body.$.user_code}}", + "scope": "mcp:read projects:read stacks:read events:read offline_access", + "organization_ids": [ + "{{organizationId}}" + ] +} + +### Device Token Poll +POST {{apiUrl}}/oauth/token +Content-Type: application/x-www-form-urlencoded + +grant_type=urn:ietf:params:oauth:grant-type:device_code&client_id={{registerDeviceOAuthClient.response.body.$.client_id}}&device_code={{startDeviceAuthorization.response.body.$.device_code}} + ### Optional: Create OAuth Application # Clients that support Client ID Metadata Documents can use an HTTPS metadata-document URL # as client_id and do not need this manual registration step. Manually create an application