diff --git a/README.md b/README.md index 7822ece..d1e63e9 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,8 @@ | Area | Supported Standards & Features | | :--- | :--- | -| **OAuth 2.0 Grants** | Authorization Code (`authorization_code`), Client Credentials (`client_credentials`), Refresh Token (`refresh_token` with rotation & family reuse detection), Device Authorization (`urn:ietf:params:oauth:grant-type:device_code`), Cryptographic Challenge-Response (`challenge_response` / `urn:ietf:params:oauth:grant-type:challenge`), Password (`password`), Implicit (`implicit`) | +| **OAuth 2.0 Grants** | Authorization Code (`authorization_code`), Client Credentials (`client_credentials`), Refresh Token (`refresh_token` with rotation & family reuse detection), Device Authorization (`urn:ietf:params:oauth:grant-type:device_code`), Cryptographic Challenge-Response (`challenge_response` / `urn:ietf:params:oauth:grant-type:challenge`), Social Sign-In (`social` / `urn:ietf:params:oauth:grant-type:social`), Password (`password`), Implicit (`implicit`) | +| **Social Sign-In** | Built-in adapters for **Google**, **GitHub**, **Apple**, **Microsoft / Entra ID**, **Discord**, and generic OAuth2/OIDC providers (`GET /oauth/social/:provider`, `GET\|POST /oauth/social/:provider/callback`, and `POST /oauth/token`) | | **OpenID Connect** | `id_token` issuance (`nonce`, `acr`, `amr`, `auth_time`), `GET /oauth/userinfo`, OIDC Discovery (`/.well-known/openid-configuration`), OAuth 2.0 Authorization Server Metadata (`/.well-known/oauth-authorization-server` — RFC 8414), JWKS (`/.well-known/jwks.json` — RFC 7517) | | **FAPI & Advanced Security** | PKCE with `S256` enforcement (RFC 7636), Pushed Authorization Requests (`POST /oauth/par` — RFC 9126), JWT Secured Authorization Response Mode (JARM — RFC 9101), Certificate-Bound Access Tokens (`cnf.x5t#S256` mTLS — RFC 8705), Dynamic Client Registration (`POST /oauth/register` — RFC 7591) | | **Token Lifecycle** | `:jwt` (`HS*`, `RS*`, `ES*`) and `:opaque` token strategies, asymmetric key rotation (`signing_key_id` + `verification_keys`), Token Introspection (`POST /oauth/introspect` — RFC 7662), Token Revocation (`POST /oauth/revoke` — RFC 7009), host-controlled `IssuanceOptions` | @@ -137,6 +138,7 @@ Practical recipes for configuring production features: - **[Enable FAPI & Advanced Security (PKCE, PAR, JARM, mTLS)](docs/how-to/fapi-and-advanced-security.md)** — Enforce `S256` PKCE, Pushed Authorization Requests (`POST /oauth/par`), JARM signed/encrypted responses, and certificate-bound tokens. - **[Configure Asymmetric Keys, Key Rotation & JWKS](docs/how-to/key-rotation-and-jwks.md)** — Sign with `RS256` / `ES256`, rotate keys without downtime via `verification_keys`, and customize claims with `IssuanceOptions`. - **[Configure Dynamic Client Registration & Device Authorization](docs/how-to/dynamic-client-registration.md)** — Enable RFC 7591 registration (`POST /oauth/register`) and RFC 8628 smart-TV/CLI device flows (`POST /oauth/device/code` & `/device`). +- **[Configure Social Sign-In](docs/how-to/social-sign-in.md)** — Integrate Google, GitHub, Apple, Microsoft, Discord, and custom OAuth2/OIDC providers via redirect callbacks or `POST /oauth/token`. ### [Reference](docs/reference/README.md) Complete technical specifications: @@ -163,6 +165,8 @@ Architectural overviews and security design: | `POST` | `/oauth/token` | Token issuance & refresh (`authorization_code`, `client_credentials`, `refresh_token`, `password`, `device_code`, `challenge_response`) | | `POST` | `/oauth/par` | Pushed Authorization Requests (RFC 9126) | | `POST` | `/oauth/challenge` | Cryptographic Challenge-Response nonce issuance | +| `GET` | `/oauth/social/:provider` | Social Sign-In redirect initiation (PKCE + signed CSRF `state`) | +| `GET`, `POST` | `/oauth/social/:provider/callback` | Social Sign-In provider callback | | `POST` | `/oauth/device/code` | Device Authorization code issuance (RFC 8628) | | `GET`, `POST` | `/device` | End-user device code verification page (RFC 8628) | | `POST` | `/oauth/introspect`, `/introspect` | Token Introspection (RFC 7662) | diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md index f66d01d..fff2cba 100644 --- a/docs/SUMMARY.md +++ b/docs/SUMMARY.md @@ -17,6 +17,7 @@ * [Enable FAPI & Advanced Security (PKCE, PAR, JARM, mTLS)](how-to/fapi-and-advanced-security.md) * [Configure Asymmetric Keys, Key Rotation & JWKS](how-to/key-rotation-and-jwks.md) * [Configure Dynamic Client Registration & Device Flow](how-to/dynamic-client-registration.md) + * [Configure Social Sign-In (Google, GitHub, Apple, Microsoft, Discord)](how-to/social-sign-in.md) ## Reference diff --git a/docs/how-to/README.md b/docs/how-to/README.md index 95f88fa..6912113 100644 --- a/docs/how-to/README.md +++ b/docs/how-to/README.md @@ -26,3 +26,7 @@ These guides show you how to solve specific problems in production—such as con {% content-ref url="dynamic-client-registration.md" %} [dynamic-client-registration.md](dynamic-client-registration.md) {% endcontent-ref %} + +{% content-ref url="social-sign-in.md" %} +[social-sign-in.md](social-sign-in.md) +{% endcontent-ref %} diff --git a/docs/how-to/social-sign-in.md b/docs/how-to/social-sign-in.md new file mode 100644 index 0000000..55ebe8f --- /dev/null +++ b/docs/how-to/social-sign-in.md @@ -0,0 +1,151 @@ +--- +description: Configure Google, GitHub, Apple, Microsoft, Discord, and custom OAuth2/OIDC social sign-in flows. +icon: users +--- + +# Configure Social Sign-In + +Authly provides built-in Social Sign-In support for **Google**, **GitHub**, **Apple**, **Microsoft / Entra ID**, **Discord**, and any custom OAuth 2.0 / OIDC identity provider (`Authly::Social::GenericOAuth2`). + +It supports two complementary flows: +1. **Browser Redirect & Callback Flow** (`GET /oauth/social/:provider` and `GET|POST /oauth/social/:provider/callback`) with automatic `S256` PKCE and single-use signed CSRF `state` tokens. +2. **Token Endpoint Grant Exchange** (`POST /oauth/token` with `grant_type=social` or `urn:ietf:params:oauth:grant-type:social`) for native mobile apps, SPAs, and backend services. + +--- + +## 1. Register Social Providers + +{% tabs %} +{% tab title="Google, GitHub & Apple" %} +```crystal +Authly.configure do |config| + config.register_social_provider( + Authly::Social::Google.new( + client_id: ENV["GOOGLE_CLIENT_ID"], + client_secret: ENV["GOOGLE_CLIENT_SECRET"], + redirect_uri: "https://auth.example.com/oauth/social/google/callback" + ) + ) + + config.register_social_provider( + Authly::Social::GitHub.new( + client_id: ENV["GITHUB_CLIENT_ID"], + client_secret: ENV["GITHUB_CLIENT_SECRET"], + redirect_uri: "https://auth.example.com/oauth/social/github/callback" + ) + ) + + config.register_social_provider( + Authly::Social::Apple.new( + client_id: ENV["APPLE_SERVICE_ID"], + client_secret: ENV["APPLE_CLIENT_SECRET"], + redirect_uri: "https://auth.example.com/oauth/social/apple/callback" + ) + ) +end +``` +{% endtab %} + +{% tab title="Microsoft, Discord & Custom Provider" %} +```crystal +Authly.configure do |config| + config.register_social_provider( + Authly::Social::Microsoft.new( + client_id: ENV["MS_CLIENT_ID"], + client_secret: ENV["MS_CLIENT_SECRET"], + redirect_uri: "https://auth.example.com/oauth/social/microsoft/callback", + tenant: "common" + ) + ) + + config.register_social_provider( + Authly::Social::Discord.new( + client_id: ENV["DISCORD_CLIENT_ID"], + client_secret: ENV["DISCORD_CLIENT_SECRET"], + redirect_uri: "https://auth.example.com/oauth/social/discord/callback" + ) + ) + + config.register_social_provider( + Authly::Social::GenericOAuth2.new( + name: "gitlab", + client_id: ENV["GITLAB_CLIENT_ID"], + client_secret: ENV["GITLAB_CLIENT_SECRET"], + redirect_uri: "https://auth.example.com/oauth/social/gitlab/callback", + authorize_endpoint: "https://gitlab.com/oauth/authorize", + token_endpoint: "https://gitlab.com/oauth/token", + userinfo_endpoint: "https://gitlab.com/oauth/userinfo" + ) + ) +end +``` +{% endtab %} +{% endtabs %} + +--- + +## 2. Map Social Profiles to Local Resource Owners + +When a user completes social sign-in, Authly normalizes their upstream identity into an `Authly::SocialProfile` (`provider`, `uid`, `email`, `email_verified?`, `name`, `first_name`, `last_name`, `nickname`, `avatar_url`, `raw_attributes`). + +You can map `SocialProfile` to a local subject ID either on your `AuthorizableOwner` store or via `config.social_owner_resolver`: + +{% tabs %} +{% tab title="config.social_owner_resolver" %} +```crystal +Authly.configure do |config| + config.social_owner_resolver = ->(profile : Authly::SocialProfile) : String? { + # Find or provision a user in your database + user = UserRepo.find_by_social(profile.provider, profile.uid) + if user.nil? && profile.email_verified? + if email = profile.email + user = UserRepo.find_or_create_by_email(email, name: profile.name, avatar: profile.avatar_url) + UserRepo.link_social(user.id, profile.provider, profile.uid) + end + end + user.try(&.id) + } +end +``` +{% endtab %} + +{% tab title="AuthorizableOwner#resolve_social_owner" %} +```crystal +class DatabaseOwners + include Authly::AuthorizableOwner + + def resolve_social_owner(profile : Authly::SocialProfile) : String? + UserRepo.resolve_or_create_from_social(profile) + end + + # ... authorized? and id_token ... +end +``` +{% endtab %} +{% endtabs %} + +{% hint style="warning" %} +**Fail-Closed Account Linking**: Only auto-link a social account to an existing email address when `profile.email_verified?` is `true`. If `resolve_social_owner` returns `nil`, Authly refuses the login with `403 access_denied`. +{% endhint %} + +--- + +## 3. Flow A: Browser Redirect & Callback (`/oauth/social/:provider`) + +1. **Direct Sign-In**: Redirect the browser to `GET /oauth/social/google?scope=read+openid`. Upon callback at `/oauth/social/google/callback`, Authly verifies the signed `state` token, consumes its single-use nonce in `state_store`, exchanges the `code` with PKCE `code_verifier`, and returns `200 OK` JSON with an `AccessToken` (or invokes `config.on_social_authenticated` to set your app's session cookie). +2. **OAuth2 Client Code Flow**: Pass `client_id`, `redirect_uri`, `scope`, and `state` to `GET /oauth/social/google?client_id=my-app&redirect_uri=https://app.example.com/cb&scope=read+openid&state=xyz`. Upon callback, Authly redirects to `https://app.example.com/cb?code=&state=xyz`. + +--- + +## 4. Flow B: Token Endpoint Grant Exchange (`POST /oauth/token`) + +Mobile apps and SPAs that obtain an authorization `code` or `subject_token` / `id_token` directly from a social SDK can exchange it at `POST /oauth/token`: + +```bash +curl -X POST https://auth.example.com/oauth/token \ + -u "my-client:my-secret" \ + -d "grant_type=social" \ + -d "provider=google" \ + -d "code=4/0AfJohXl..." \ + -d "scope=read openid" +``` diff --git a/spec/social_auth_spec.cr b/spec/social_auth_spec.cr new file mode 100644 index 0000000..d26b34b --- /dev/null +++ b/spec/social_auth_spec.cr @@ -0,0 +1,517 @@ +require "./spec_helper" + +private class MockSocialHttpClient + include Authly::Social::HttpClient + + property post_responses = Hash(String, Authly::Social::HttpResponse).new + property get_responses = Hash(String, Authly::Social::HttpResponse).new + property last_post_params = Hash(String, String).new + + def post_form( + url : String, + params : Hash(String, String), + headers : HTTP::Headers = HTTP::Headers.new, + ) : Authly::Social::HttpResponse + @last_post_params = params + @post_responses[url]? || Authly::Social::HttpResponse.new(404, %({"error":"not_found"})) + end + + def get( + url : String, + headers : HTTP::Headers = HTTP::Headers.new, + ) : Authly::Social::HttpResponse + @get_responses[url]? || Authly::Social::HttpResponse.new(404, %({"error":"not_found"})) + end +end + +private def reset_social_test_config!(mock_http : MockSocialHttpClient) + Authly.config.social_providers.clear + Authly.config.social_owner_resolver = nil + Authly.config.on_social_authenticated = nil + Authly.config.social_http_client = mock_http + Authly.config.state_store = Authly::InMemoryStateStore.new +end + +describe "Social Sign-In Authentication" do + describe "Built-in Social Providers" do + it "exchanges code and builds profile for Google" do + mock_http = MockSocialHttpClient.new + reset_social_test_config!(mock_http) + + mock_http.post_responses[Authly::Social::Google::TOKEN_URL] = Authly::Social::HttpResponse.new( + 200, + { + access_token: "goog-access-123", + refresh_token: "goog-refresh-123", + expires_in: 3600, + }.to_json + ) + mock_http.get_responses[Authly::Social::Google::USERINFO_URL] = Authly::Social::HttpResponse.new( + 200, + { + sub: "google-uid-999", + email: "alice@example.com", + email_verified: true, + name: "Alice Smith", + given_name: "Alice", + family_name: "Smith", + picture: "https://example.com/alice.png", + }.to_json + ) + + provider = Authly::Social::Google.new( + client_id: "goog-client", + client_secret: "goog-secret", + redirect_uri: "http://localhost:8080/oauth/social/google/callback" + ) + + auth_url = provider.authorize_url(state: "state-1", code_challenge: "challenge-1") + auth_url.should contain("https://accounts.google.com/o/oauth2/v2/auth") + auth_url.should contain("code_challenge=challenge-1") + auth_url.should contain("code_challenge_method=S256") + + profile = provider.exchange_code("goog-code", code_verifier: "verifier-1") + mock_http.last_post_params["code_verifier"]?.should eq("verifier-1") + profile.provider.should eq("google") + profile.uid.should eq("google-uid-999") + profile.email.should eq("alice@example.com") + profile.email_verified?.should be_true + profile.name.should eq("Alice Smith") + profile.first_name.should eq("Alice") + profile.last_name.should eq("Smith") + profile.avatar_url.should eq("https://example.com/alice.png") + end + + it "exchanges code and fetches primary verified email from /user/emails for GitHub" do + mock_http = MockSocialHttpClient.new + reset_social_test_config!(mock_http) + + mock_http.post_responses[Authly::Social::GitHub::TOKEN_URL] = Authly::Social::HttpResponse.new( + 200, + {access_token: "gh-access-123"}.to_json + ) + mock_http.get_responses[Authly::Social::GitHub::USERINFO_URL] = Authly::Social::HttpResponse.new( + 200, + { + id: 424242, + login: "octocat", + name: "Mona Octocat", + email: nil, + avatar_url: "https://avatars.githubusercontent.com/u/424242", + }.to_json + ) + mock_http.get_responses[Authly::Social::GitHub::EMAILS_URL] = Authly::Social::HttpResponse.new( + 200, + [ + {email: "secondary@example.com", primary: false, verified: true}, + {email: "mona@github.com", primary: true, verified: true}, + ].to_json + ) + + provider = Authly::Social::GitHub.new( + client_id: "gh-client", + client_secret: "gh-secret", + redirect_uri: "http://localhost:8080/oauth/social/github/callback" + ) + + profile = provider.exchange_code("gh-code") + profile.provider.should eq("github") + profile.uid.should eq("424242") + profile.nickname.should eq("octocat") + profile.name.should eq("Mona Octocat") + profile.email.should eq("mona@github.com") + profile.email_verified?.should be_true + end + + it "verifies id_token and merges first-login user JSON for Apple" do + mock_http = MockSocialHttpClient.new + reset_social_test_config!(mock_http) + + apple_id_token = JWT.encode( + { + "iss" => "https://appleid.apple.com", + "aud" => "com.example.app", + "sub" => "apple-user-001", + "email" => "appleuser@privaterelay.appleid.com", + "email_verified" => "true", + "exp" => (Time.utc + 10.minutes).to_unix, + }, + "apple-test-secret", + JWT::Algorithm::HS256 + ) + + mock_http.post_responses[Authly::Social::Apple::TOKEN_URL] = Authly::Social::HttpResponse.new( + 200, + { + access_token: "apple-access-123", + id_token: apple_id_token, + expires_in: 3600, + }.to_json + ) + + provider = Authly::Social::Apple.new( + client_id: "com.example.app", + client_secret: "apple-client-secret", + redirect_uri: "https://example.com/oauth/social/apple/callback" + ) + + auth_url = provider.authorize_url(state: "st-apple") + auth_url.should contain("response_mode=form_post") + + user_json = {name: {firstName: "Jane", lastName: "Appleseed"}}.to_json + profile = provider.exchange_code("apple-code", extra_params: {"user" => user_json}) + profile.provider.should eq("apple") + profile.uid.should eq("apple-user-001") + profile.email.should eq("appleuser@privaterelay.appleid.com") + profile.email_verified?.should be_true + profile.first_name.should eq("Jane") + profile.last_name.should eq("Appleseed") + profile.name.should eq("Jane Appleseed") + end + + it "exchanges code and builds profile for Microsoft Entra ID and Discord and GenericOAuth2" do + mock_http = MockSocialHttpClient.new + reset_social_test_config!(mock_http) + + ms = Authly::Social::Microsoft.new( + client_id: "ms-client", + client_secret: "ms-secret", + redirect_uri: "http://localhost:8080/oauth/social/microsoft/callback", + tenant: "contoso.onmicrosoft.com" + ) + ms.authorize_endpoint.should eq("https://login.microsoftonline.com/contoso.onmicrosoft.com/oauth2/v2.0/authorize") + mock_http.post_responses[ms.token_endpoint] = Authly::Social::HttpResponse.new( + 200, + {access_token: "ms-access-token"}.to_json + ) + mock_http.get_responses[Authly::Social::Microsoft::USERINFO_URL] = Authly::Social::HttpResponse.new( + 200, + { + sub: "ms-uid-77", + preferred_username: "bob@contoso.com", + name: "Bob Contoso", + }.to_json + ) + + ms_profile = ms.exchange_code("ms-code") + ms_profile.uid.should eq("ms-uid-77") + ms_profile.email.should eq("bob@contoso.com") + ms_profile.email_verified?.should be_true + + discord = Authly::Social::Discord.new( + client_id: "disc-client", + client_secret: "disc-secret", + redirect_uri: "http://localhost:8080/oauth/social/discord/callback" + ) + mock_http.post_responses[Authly::Social::Discord::TOKEN_URL] = Authly::Social::HttpResponse.new( + 200, + {access_token: "disc-access-token"}.to_json + ) + mock_http.get_responses[Authly::Social::Discord::USERINFO_URL] = Authly::Social::HttpResponse.new( + 200, + { + id: "80351110224678912", + username: "nelly", + global_name: "Nelly", + avatar: "8342729096ea3675442027381ff50dfe", + email: "nelly@discord.com", + verified: true, + }.to_json + ) + + disc_profile = discord.exchange_code("disc-code") + disc_profile.uid.should eq("80351110224678912") + disc_profile.avatar_url.should eq("https://cdn.discordapp.com/avatars/80351110224678912/8342729096ea3675442027381ff50dfe.png") + + generic = Authly::Social::GenericOAuth2.new( + name: "gitlab", + client_id: "gl-client", + client_secret: "gl-secret", + redirect_uri: "http://localhost:8080/oauth/social/gitlab/callback", + authorize_endpoint: "https://gitlab.example.com/oauth/authorize", + token_endpoint: "https://gitlab.example.com/oauth/token", + userinfo_endpoint: "https://gitlab.example.com/oauth/userinfo" + ) + mock_http.post_responses[generic.token_endpoint] = Authly::Social::HttpResponse.new( + 200, + {access_token: "gl-access"}.to_json + ) + mock_http.get_responses[generic.userinfo_endpoint] = Authly::Social::HttpResponse.new( + 200, + { + sub: "gl-101", + email: "dev@gitlab.example.com", + email_verified: true, + name: "GitLab Dev", + }.to_json + ) + + gl_profile = generic.exchange_code("gl-code") + gl_profile.provider.should eq("gitlab") + gl_profile.uid.should eq("gl-101") + gl_profile.email.should eq("dev@gitlab.example.com") + end + end + + describe "HTTP Redirect & Callback Flow (/oauth/social/:provider)" do + it "initiates redirect, verifies callback state once, resolves owner, and prevents state replay" do + mock_http = MockSocialHttpClient.new + reset_social_test_config!(mock_http) + + google = Authly::Social::Google.new( + client_id: "goog-client", + client_secret: "goog-secret", + redirect_uri: "http://localhost:8080/oauth/social/google/callback" + ) + Authly.config.register_social_provider(google) + + Authly.owners << Authly::Owner.new( + username: "social_alice", + password: "unused-password", + id: "owner-social-alice", + email: "alice@example.com", + social_identities: {"google" => "google-uid-999"} + ) + + mock_http.post_responses[Authly::Social::Google::TOKEN_URL] = Authly::Social::HttpResponse.new( + 200, + {access_token: "goog-access-123"}.to_json + ) + mock_http.get_responses[Authly::Social::Google::USERINFO_URL] = Authly::Social::HttpResponse.new( + 200, + { + sub: "google-uid-999", + email: "alice@example.com", + email_verified: true, + name: "Alice Smith", + }.to_json + ) + + handler = Authly::Handler.new + + # Step 1: GET /oauth/social/google + start_req = HTTP::Request.new("GET", "/oauth/social/google?scope=read+openid") + start_io = IO::Memory.new + start_ctx = HTTP::Server::Context.new(start_req, HTTP::Server::Response.new(start_io)) + handler.call(start_ctx) + start_ctx.response.close + start_io.rewind + start_res = HTTP::Client::Response.from_io(start_io) + + start_res.status_code.should eq(302) + location = URI.parse(start_res.headers["Location"]) + location.host.should eq("accounts.google.com") + query_params = URI::Params.parse(location.query || "") + state = query_params["state"] + query_params["code_challenge_method"].should eq("S256") + query_params["code_challenge"].should_not be_empty + + # Step 2: GET /oauth/social/google/callback + cb_req = HTTP::Request.new("GET", "/oauth/social/google/callback?code=goog-auth-code&state=#{URI.encode_www_form(state)}") + cb_io = IO::Memory.new + cb_ctx = HTTP::Server::Context.new(cb_req, HTTP::Server::Response.new(cb_io)) + handler.call(cb_ctx) + cb_ctx.response.close + cb_io.rewind + cb_res = HTTP::Client::Response.from_io(cb_io) + + cb_res.status_code.should eq(200) + body = JSON.parse(cb_res.body) + body["sub"].as_s.should eq("owner-social-alice") + body["provider"].as_s.should eq("google") + body["social_uid"].as_s.should eq("google-uid-999") + body["access_token"].as_s.should_not be_empty + body["id_token"].as_s.should_not be_empty + + # Step 3: Replay with the same state token must fail (nonce consumed) + replay_req = HTTP::Request.new("GET", "/oauth/social/google/callback?code=goog-auth-code&state=#{URI.encode_www_form(state)}") + replay_io = IO::Memory.new + replay_ctx = HTTP::Server::Context.new(replay_req, HTTP::Server::Response.new(replay_io)) + handler.call(replay_ctx) + replay_ctx.response.close + replay_io.rewind + replay_res = HTTP::Client::Response.from_io(replay_io) + + replay_res.status_code.should eq(400) + JSON.parse(replay_res.body)["error"].as_s.should eq("invalid_state") + end + + it "redirects back to OAuth client redirect_uri with an Authly authorization code when client_id is provided" do + mock_http = MockSocialHttpClient.new + reset_social_test_config!(mock_http) + + github = Authly::Social::GitHub.new( + client_id: "gh-client", + client_secret: "gh-secret", + redirect_uri: "http://localhost:8080/oauth/social/github/callback", + scopes: ["read:user"] + ) + Authly.config.register_social_provider(github) + Authly.config.social_owner_resolver = ->(profile : Authly::SocialProfile) : String? { + "gh-user-#{profile.uid}" + } + + mock_http.post_responses[Authly::Social::GitHub::TOKEN_URL] = Authly::Social::HttpResponse.new( + 200, + {access_token: "gh-token-abc"}.to_json + ) + mock_http.get_responses[Authly::Social::GitHub::USERINFO_URL] = Authly::Social::HttpResponse.new( + 200, + {id: 777, login: "crystaldev"}.to_json + ) + + handler = Authly::Handler.new + + start_req = HTTP::Request.new( + "GET", + "/oauth/social/github?client_id=1&redirect_uri=#{URI.encode_www_form("https://www.example.com/callback")}&scope=read&state=client-xyz" + ) + start_io = IO::Memory.new + start_ctx = HTTP::Server::Context.new(start_req, HTTP::Server::Response.new(start_io)) + handler.call(start_ctx) + start_ctx.response.close + start_io.rewind + start_res = HTTP::Client::Response.from_io(start_io) + + start_res.status_code.should eq(302) + state = URI::Params.parse(URI.parse(start_res.headers["Location"]).query || "")["state"] + + cb_req = HTTP::Request.new("GET", "/oauth/social/github/callback?code=gh-code-1&state=#{URI.encode_www_form(state)}") + cb_io = IO::Memory.new + cb_ctx = HTTP::Server::Context.new(cb_req, HTTP::Server::Response.new(cb_io)) + handler.call(cb_ctx) + cb_ctx.response.close + cb_io.rewind + cb_res = HTTP::Client::Response.from_io(cb_io) + + cb_res.status_code.should eq(302) + cb_location = URI.parse(cb_res.headers["Location"]) + cb_params = URI::Params.parse(cb_location.query || "") + cb_params["state"].should eq("client-xyz") + authly_code = cb_params["code"] + authly_code.should_not be_empty + + # Exchange the issued Authly authorization code at POST /oauth/token + token = Authly.access_token( + "authorization_code", + client_id: "1", + client_secret: "secret", + redirect_uri: "https://www.example.com/callback", + code: authly_code + ) + token.sub.should eq("gh-user-777") + end + end + + describe "Token Endpoint Social Grant (POST /oauth/token)" do + it "exchanges social code or subject_token via grant_type=social and urn:ietf:params:oauth:grant-type:social" do + mock_http = MockSocialHttpClient.new + reset_social_test_config!(mock_http) + + google = Authly::Social::Google.new( + client_id: "goog-client", + client_secret: "goog-secret", + redirect_uri: "http://localhost:8080/oauth/social/google/callback" + ) + Authly.config.register_social_provider(google) + Authly.config.social_owner_resolver = ->(profile : Authly::SocialProfile) : String? { + "resolved-#{profile.provider}-#{profile.uid}" + } + + mock_http.post_responses[Authly::Social::Google::TOKEN_URL] = Authly::Social::HttpResponse.new( + 200, + {access_token: "goog-token-99"}.to_json + ) + mock_http.get_responses[Authly::Social::Google::USERINFO_URL] = Authly::Social::HttpResponse.new( + 200, + { + sub: "987654", + email: "user@example.com", + email_verified: true, + }.to_json + ) + + handler = Authly::Handler.new + + # 1. Exchange via code with grant_type=social + body = URI::Params.encode({ + "grant_type" => "social", + "client_id" => "1", + "client_secret" => "secret", + "provider" => "google", + "code" => "upstream-google-code", + "scope" => "read openid", + }) + req = HTTP::Request.new( + "POST", + "/oauth/token", + headers: HTTP::Headers{"Content-Type" => "application/x-www-form-urlencoded"}, + body: body + ) + io = IO::Memory.new + ctx = HTTP::Server::Context.new(req, HTTP::Server::Response.new(io)) + handler.call(ctx) + ctx.response.close + io.rewind + res = HTTP::Client::Response.from_io(io) + + res.status_code.should eq(200) + token_json = JSON.parse(res.body) + token_json["sub"].as_s.should eq("resolved-google-987654") + token_json["access_token"].as_s.should_not be_empty + token_json["id_token"].as_s.should_not be_empty + + # 2. Exchange via subject_token with urn:ietf:params:oauth:grant-type:social + body2 = URI::Params.encode({ + "grant_type" => "urn:ietf:params:oauth:grant-type:social", + "client_id" => "1", + "provider" => "google", + "subject_token" => "goog-token-99", + "scope" => "read", + }) + req2 = HTTP::Request.new( + "POST", + "/oauth/token", + headers: HTTP::Headers{"Content-Type" => "application/x-www-form-urlencoded"}, + body: body2 + ) + io2 = IO::Memory.new + ctx2 = HTTP::Server::Context.new(req2, HTTP::Server::Response.new(io2)) + handler.call(ctx2) + ctx2.response.close + io2.rewind + res2 = HTTP::Client::Response.from_io(io2) + + res2.status_code.should eq(200) + JSON.parse(res2.body)["sub"].as_s.should eq("resolved-google-987654") + end + + it "returns access_denied when social_owner_resolver returns nil" do + mock_http = MockSocialHttpClient.new + reset_social_test_config!(mock_http) + + google = Authly::Social::Google.new( + client_id: "goog-client", + client_secret: "goog-secret", + redirect_uri: "http://localhost:8080/oauth/social/google/callback" + ) + Authly.config.register_social_provider(google) + Authly.config.social_owner_resolver = ->(_profile : Authly::SocialProfile) : String? { nil } + + mock_http.get_responses[Authly::Social::Google::USERINFO_URL] = Authly::Social::HttpResponse.new( + 200, + {sub: "unmapped-uid-000", email: "nobody@example.com", email_verified: false}.to_json + ) + + expect_raises(Authly::Error, "The user or authorization server denied the request") do + Authly.access_token( + "social", + client_id: "1", + client_secret: "secret", + provider: "google", + subject_token: "some-upstream-token" + ) + end + end + end +end diff --git a/spec/support/settings.cr b/spec/support/settings.cr index f81ffb3..20ca338 100644 --- a/spec/support/settings.cr +++ b/spec/support/settings.cr @@ -12,8 +12,8 @@ Authly.configure do |config| config.authenticate_user = ->(context : HTTP::Server::Context) { context.request.headers["X-Test-User"]?.presence } end -Authly.clients << Authly::Client.new("example", "secret", "https://www.example.com/callback", "1", "read write admin openid", "authorization_code client_credentials password refresh_token implicit challenge_response urn:ietf:params:oauth:grant-type:challenge") -Authly.clients << Authly::Client.new("test_client", "test_secret", "https://example.com/callback", "test_client", "read write admin openid profile email", "authorization_code client_credentials password refresh_token implicit challenge_response urn:ietf:params:oauth:grant-type:challenge") +Authly.clients << Authly::Client.new("example", "secret", "https://www.example.com/callback", "1", "read write admin openid", "authorization_code client_credentials password refresh_token implicit challenge_response urn:ietf:params:oauth:grant-type:challenge social urn:ietf:params:oauth:grant-type:social") +Authly.clients << Authly::Client.new("test_client", "test_secret", "https://example.com/callback", "test_client", "read write admin openid profile email", "authorization_code client_credentials password refresh_token implicit challenge_response urn:ietf:params:oauth:grant-type:challenge social urn:ietf:params:oauth:grant-type:social") Authly.owners << Authly::Owner.new("username", "password") Authly.owners << Authly::Owner.new("john", "secret") diff --git a/src/authly.cr b/src/authly.cr index cc4b15c..5a5699c 100644 --- a/src/authly.cr +++ b/src/authly.cr @@ -1,5 +1,9 @@ require "jwt" require "json" +require "./authly/social/social_profile" +require "./authly/social/http_client" +require "./authly/social/provider" +require "./authly/social/providers/*" require "./authly/authorizable_owner" require "./authly/authorizable_client" require "./authly/token_store" diff --git a/src/authly/authorizable_owner.cr b/src/authly/authorizable_owner.cr index 86f5d48..c2c884b 100644 --- a/src/authly/authorizable_owner.cr +++ b/src/authly/authorizable_owner.cr @@ -14,5 +14,9 @@ module Authly ) : Bool false end + + def resolve_social_owner(profile : SocialProfile) : String? + nil + end end end diff --git a/src/authly/client.cr b/src/authly/client.cr index bd926f3..3280c19 100644 --- a/src/authly/client.cr +++ b/src/authly/client.cr @@ -19,7 +19,7 @@ module Authly @redirect_uri, @id, @scopes = "read", - @grant_types = "authorization_code client_credentials password refresh_token challenge_response urn:ietf:params:oauth:grant-type:challenge", + @grant_types = "authorization_code client_credentials password refresh_token challenge_response urn:ietf:params:oauth:grant-type:challenge social urn:ietf:params:oauth:grant-type:social", @jarm_signing_alg = nil, @jarm_encryption_alg = nil, @jarm_encryption_enc = nil, diff --git a/src/authly/configuration.cr b/src/authly/configuration.cr index e513ad9..8a2f58e 100644 --- a/src/authly/configuration.cr +++ b/src/authly/configuration.cr @@ -23,7 +23,7 @@ module Authly # Trusted host callback; never populated from OAuth request parameters. property issuance_options : Proc(String, String, IssuanceOptions)? = nil property? issue_refresh_tokens : Bool = true - property grant_types : Array(String) = %w[authorization_code client_credentials password refresh_token implicit urn:ietf:params:oauth:grant-type:device_code challenge_response urn:ietf:params:oauth:grant-type:challenge] + property grant_types : Array(String) = %w[authorization_code client_credentials password refresh_token implicit urn:ietf:params:oauth:grant-type:device_code challenge_response urn:ietf:params:oauth:grant-type:challenge social urn:ietf:params:oauth:grant-type:social] property signing_key_id : String? = nil property verification_keys : Hash(String, String) = {} of String => String @@ -117,6 +117,17 @@ module Authly property challenge_ttl : Time::Span = 60.seconds property challenge_max_attempts : Int32 = 1 + # Social Sign-In Authentication + property social_providers : Hash(String, Social::Provider) = {} of String => Social::Provider + property social_owner_resolver : Proc(SocialProfile, String?)? = nil + property on_social_authenticated : Proc(HTTP::Server::Context, SocialProfile, String, AccessToken?, Nil)? = nil + property social_http_client : Social::HttpClient = Social::StdlibHttpClient.new + property social_state_ttl : Time::Span = 10.minutes + + def register_social_provider(provider : Social::Provider) : Social::Provider + @social_providers[provider.name] = provider + end + # JWT Secured Authorization Response Mode (JARM) property jarm_signing_key : String? = nil property jarm_signing_alg : JWT::Algorithm = JWT::Algorithm::RS256 diff --git a/src/authly/grant.cr b/src/authly/grant.cr index 8b8518e..443cf64 100644 --- a/src/authly/grant.cr +++ b/src/authly/grant.cr @@ -88,6 +88,19 @@ module Authly algorithm: args[:algorithm]?.try(&.to_s), grant_type: grant_type, ) + when "social", "urn:ietf:params:oauth:grant-type:social" + SocialGrant.new( + client_id: args.fetch(:client_id, "").to_s, + client_secret: args.fetch(:client_secret, "").to_s, + provider_name: args.fetch(:provider, "").to_s, + code: args.fetch(:code, "").to_s, + redirect_uri: args.fetch(:redirect_uri, "").to_s, + verifier: args.fetch(:verifier, "").to_s, + subject_token: args.fetch(:subject_token, "").to_s, + id_token: args.fetch(:id_token, "").to_s, + scope: args.fetch(:scope, "").to_s, + grant_type: grant_type, + ) else raise Error.unsupported_grant_type end @@ -127,13 +140,31 @@ module Authly end private def generate_id_token - if !@code.empty? && scope.split.includes?("openid") + return unless scope.split.includes?("openid") + + if !@code.empty? && @grant_strategy.is_a?(AuthorizationCode) user_id = auth_code["user_id"].as_s payload = Authly.owners.id_token(user_id) payload["nonce"] = auth_code["nonce"].as_s unless auth_code["nonce"]?.try(&.as_s?).to_s.empty? payload["iss"] = Authly.config.issuer payload["aud"] = @client_id Authly.jwt_encode(payload) + elsif sub = @grant_strategy.subject + payload = + begin + Authly.owners.id_token(sub) + rescue + { + "user_id" => sub, + "sub" => sub, + "iat" => Time.utc.to_unix, + "exp" => Authly.config.access_ttl.from_now.to_unix, + "iss" => Authly.config.issuer, + } of String => String | Int64 + end + payload["iss"] = Authly.config.issuer + payload["aud"] = @client_id + Authly.jwt_encode(payload) end end diff --git a/src/authly/grants/social.cr b/src/authly/grants/social.cr new file mode 100644 index 0000000..ec9c4e4 --- /dev/null +++ b/src/authly/grants/social.cr @@ -0,0 +1,96 @@ +module Authly + class SocialGrant + include GrantStrategy + + GRANT_TYPES = [ + "social", + "urn:ietf:params:oauth:grant-type:social", + ] + + getter client_id : String + getter client_secret : String + getter provider_name : String + getter code : String + getter redirect_uri : String + getter verifier : String + getter subject_token : String + getter id_token : String + getter scope : String + getter grant_type : String + getter subject : String? = nil + getter profile : SocialProfile? = nil + + def initialize( + @client_id : String, + @client_secret : String = "", + @provider_name : String = "", + @code : String = "", + @redirect_uri : String = "", + @verifier : String = "", + @subject_token : String = "", + @id_token : String = "", + @scope : String = "", + @grant_type : String = "social", + ) + end + + def authorized? : Bool + raise Error.invalid_request if @client_id.empty? || @provider_name.empty? + raise Error.invalid_request if @code.empty? && @subject_token.empty? && @id_token.empty? + + validate_client! + + provider = Authly.config.social_providers[@provider_name]? + raise Error.invalid_request unless provider + + social_profile = + if !@code.empty? + provider.exchange_code( + code: @code, + redirect_uri: @redirect_uri.presence, + code_verifier: @verifier.presence, + ) + else + provider.verify_token( + access_token: @subject_token.presence, + id_token: @id_token.presence, + ) + end + + @profile = social_profile + resolved_subject = SocialGrant.resolve_owner(social_profile) + raise Error.access_denied unless resolved_subject + + @subject = owner_subject(resolved_subject) + true + end + + def granted_scope : String? + @scope.presence || "read" + end + + def self.resolve_owner(profile : SocialProfile) : String? + if resolver = Authly.config.social_owner_resolver + if sub = resolver.call(profile).try(&.strip).presence + return sub + end + end + + Authly.owners.resolve_social_owner(profile).try(&.strip).presence + end + + private def validate_client! : Nil + if @client_secret.empty? + raise Error.invalid_client unless Authly.clients.valid_client?(@client_id) + else + raise Error.invalid_client unless Authly.clients.authorized?(@client_id, @client_secret) + end + + unless Authly.clients.allowed_grant_type?(@client_id, @grant_type) || + Authly.clients.allowed_grant_type?(@client_id, "social") || + Authly.clients.allowed_grant_type?(@client_id, "urn:ietf:params:oauth:grant-type:social") + raise Error.unauthorized_client + end + end + end +end diff --git a/src/authly/handler.cr b/src/authly/handler.cr index 7f4cc5c..18c79e0 100644 --- a/src/authly/handler.cr +++ b/src/authly/handler.cr @@ -33,6 +33,23 @@ module Authly handle_if_methods(context, ["GET", "HEAD"]) { handle_well_known(context, path) } when "/oauth/register" handle_registration_endpoint(context) + else + handle_social_endpoint(context, path) + end + end + + private def handle_social_endpoint(context, path : String) : Bool + return false unless path.starts_with?("/oauth/social/") + + segments = path.lchop("/oauth/social/").split('/', remove_empty: true) + case segments.size + when 1 + provider_name = segments[0] + handle_if_method(context, "GET") { SocialAuthHandler.handle_start(context, provider_name) } + when 2 + return false unless segments[1] == "callback" + provider_name = segments[0] + handle_if_methods(context, ["GET", "POST"]) { SocialAuthHandler.handle_callback(context, provider_name) } else false end diff --git a/src/authly/handlers/access_token_handler.cr b/src/authly/handlers/access_token_handler.cr index dff60ab..c7a6751 100644 --- a/src/authly/handlers/access_token_handler.cr +++ b/src/authly/handlers/access_token_handler.cr @@ -19,6 +19,9 @@ module Authly response = params.fetch("response", "") key_id = params["key_id"]? algorithm = params["algorithm"]? + provider = params.fetch("provider", "") + subject_token = params.fetch("subject_token", params.fetch("access_token", "")) + id_token = params.fetch("id_token", "") scope = params["scope"]? # Extract certificate thumbprint for mTLS binding @@ -39,6 +42,9 @@ module Authly response: response, key_id: key_id, algorithm: algorithm, + provider: provider, + subject_token: subject_token, + id_token: id_token, scope: scope, cert_thumbprint: cert_thumbprint, options: Authly.config.issuance_options.try(&.call(client_id, grant_type)), @@ -51,7 +57,7 @@ module Authly end private def self.authenticate_client(context : HTTP::Server::Context, grant_type : String) : String? - if ChallengeResponse::GRANT_TYPES.includes?(grant_type) + if ChallengeResponse::GRANT_TYPES.includes?(grant_type) || SocialGrant::GRANT_TYPES.includes?(grant_type) has_basic = context.request.headers["Authorization"]?.try(&.matches?(/\ABasic(\s|\z)/i)) || false client_id, client_secret = ClientAuthentication.credentials(context) if has_basic || client_secret diff --git a/src/authly/handlers/discovery_handler.cr b/src/authly/handlers/discovery_handler.cr index fe61cec..106142d 100644 --- a/src/authly/handlers/discovery_handler.cr +++ b/src/authly/handlers/discovery_handler.cr @@ -15,6 +15,7 @@ module Authly device_authorization_endpoint: "#{base_url}/oauth/device/code", pushed_authorization_request_endpoint: "#{base_url}/oauth/par", challenge_endpoint: "#{base_url}/oauth/challenge", + social_providers_supported: Authly.config.social_providers.keys, scopes_supported: ["openid", "profile", "email", "address", "phone", "offline_access"], response_types_supported: Authly.config.grant_types.includes?("implicit") ? ["code", "token"] : ["code"], response_modes_supported: ["query", "fragment", "form_post", "query.jwt", "fragment.jwt", "form_post.jwt", "jwt"], diff --git a/src/authly/handlers/social_auth_handler.cr b/src/authly/handlers/social_auth_handler.cr new file mode 100644 index 0000000..f9b3483 --- /dev/null +++ b/src/authly/handlers/social_auth_handler.cr @@ -0,0 +1,300 @@ +require "base64" +require "json" +require "openssl/digest" +require "random/secure" +require "uri" + +module Authly + class SocialAuthHandler + SOCIAL_STATE_PURPOSE = "social_state" + + def self.handle_start(context : HTTP::Server::Context, provider_name : String) : Nil + provider = Authly.config.social_providers[provider_name]? + unless provider + ResponseHelper.write(context, 404, "application/json", { + error: "invalid_request", + error_description: "Unknown social provider", + }.to_json) + return + end + + query = context.request.query_params + client_id = query.fetch("client_id", "") + redirect_uri = query.fetch("redirect_uri", "") + scope = query.fetch("scope", "") + client_state = query.fetch("state", "") + response_type = query.fetch("response_type", "code") + oidc_nonce = query.fetch("nonce", "") + + unless client_id.empty? + validate_oauth_client!(client_id, redirect_uri, scope, response_type) + end + + state_nonce = Random::Secure.hex(16) + Authly.config.state_store.store(state_nonce, Authly.config.social_state_ttl) + + code_verifier = Random::Secure.hex(32) + code_challenge = pkce_s256_challenge(code_verifier) + + claims = { + "provider" => provider.name, + "nonce" => state_nonce, + "code_verifier" => code_verifier, + "client_id" => client_id, + "redirect_uri" => redirect_uri, + "scope" => scope, + "client_state" => client_state, + "response_type" => response_type, + "oidc_nonce" => oidc_nonce, + } + + signed_state = FormToken.issue(SOCIAL_STATE_PURPOSE, claims, Authly.config.social_state_ttl) + authorize_location = provider.authorize_url( + state: signed_state, + code_challenge: code_challenge, + code_challenge_method: "S256", + nonce: oidc_nonce.presence, + ) + + ResponseHelper.redirect(context, authorize_location) + rescue e : Error + ResponseHelper.write(context, e.code, "application/json", { + error: e.type.to_s, + error_description: e.message, + }.to_json) + end + + def self.handle_callback(context : HTTP::Server::Context, provider_name : String) : Nil + provider = Authly.config.social_providers[provider_name]? + unless provider + ResponseHelper.write(context, 404, "application/json", { + error: "invalid_request", + error_description: "Unknown social provider", + }.to_json) + return + end + + params = extract_callback_params(context) + check_upstream_error!(params) + claims = verify_callback_state!(params.fetch("state", ""), provider.name) + profile = exchange_callback_profile(provider, params, claims) + + resolved_owner = SocialGrant.resolve_owner(profile) + unless resolved_owner + ResponseHelper.write(context, 403, "application/json", { + error: "access_denied", + error_description: "No resource owner matched the social identity", + }.to_json) + return + end + + subject = resolve_subject(resolved_owner) + complete_authentication(context, provider, profile, subject, claims) + rescue e : Error + ResponseHelper.write(context, e.code, "application/json", { + error: e.type.to_s, + error_description: e.message, + }.to_json) + end + + private def self.check_upstream_error!(params : Hash(String, String)) : Nil + if upstream_error = params["error"]?.presence + raise Error.access_denied if upstream_error == "access_denied" + raise Error.invalid_grant + end + end + + private def self.verify_callback_state!(state_token : String, provider_name : String) : Hash(String, String) + claims = FormToken.verify(SOCIAL_STATE_PURPOSE, state_token) + raise Error.invalid_state unless claims + raise Error.invalid_state unless claims["provider"]? == provider_name + + state_nonce = claims["nonce"]? + raise Error.invalid_state unless state_nonce && Authly.config.state_store.consume(state_nonce) + claims + end + + private def self.exchange_callback_profile( + provider : Social::Provider, + params : Hash(String, String), + claims : Hash(String, String), + ) : SocialProfile + code = params.fetch("code", "") + id_token = params["id_token"]? + extra_params = {} of String => String + if user_json = params["user"]? + extra_params["user"] = user_json + end + + if !code.empty? + provider.exchange_code( + code: code, + code_verifier: claims["code_verifier"]?.presence, + extra_params: extra_params, + ) + elsif id_token && !id_token.empty? + provider.verify_token(id_token: id_token, extra_params: extra_params) + else + raise Error.invalid_request + end + end + + private def self.complete_authentication( + context : HTTP::Server::Context, + provider : Social::Provider, + profile : SocialProfile, + subject : String, + claims : Hash(String, String), + ) : Nil + client_id = claims.fetch("client_id", "") + redirect_uri = claims.fetch("redirect_uri", "") + scope = claims.fetch("scope", "").presence || "read" + client_state = claims.fetch("client_state", "") + response_type = claims.fetch("response_type", "code") + oidc_nonce = claims.fetch("oidc_nonce", "") + + if callback_hook = Authly.config.on_social_authenticated + token = client_id.empty? ? nil : issue_token(client_id, scope, subject, oidc_nonce) + callback_hook.call(context, profile, subject, token) + return + end + + if !client_id.empty? && !redirect_uri.empty? + if response_type == "token" + token = issue_token(client_id, scope, subject, oidc_nonce) + redirect_with_params( + context, + redirect_uri, + { + "access_token" => token.access_token, + "token_type" => "Bearer", + "expires_in" => token.expires_in.to_s, + "scope" => scope, + "state" => client_state, + }, + fragment: true, + ) + else + code = Authly.code("code", client_id, redirect_uri, scope, "", "", subject, oidc_nonce) + redirect_with_params( + context, + redirect_uri, + {"code" => code.to_s, "state" => client_state}, + fragment: false, + ) + end + return + end + + effective_client_id = client_id.presence || "social:#{provider.name}" + token = issue_token(effective_client_id, scope, subject, oidc_nonce) + context.response.headers["Cache-Control"] = "no-store" + ResponseHelper.write(context, 200, "application/json", { + sub: subject, + jti: token.jti, + access_token: token.access_token, + token_type: token.token_type, + scope: token.scope, + expires_in: token.expires_in, + refresh_token: token.refresh_token, + id_token: token.id_token, + provider: profile.provider, + social_uid: profile.uid, + }.to_json) + end + + private def self.issue_token( + client_id : String, + scope : String, + subject : String, + nonce : String = "", + ) : AccessToken + id_jwt = + if scope.split.includes?("openid") + payload = build_id_token_payload(subject, client_id, nonce) + Authly.jwt_encode(payload) + end + + AccessToken.new(client_id, scope, id_jwt, nil, subject) + end + + private def self.build_id_token_payload( + subject : String, + client_id : String, + nonce : String, + ) : Hash(String, String | Int64) + payload = + begin + Authly.owners.id_token(subject) + rescue + { + "user_id" => subject, + "sub" => subject, + "iat" => Time.utc.to_unix, + "exp" => Authly.config.access_ttl.from_now.to_unix, + "iss" => Authly.config.issuer, + } of String => String | Int64 + end + payload["nonce"] = nonce unless nonce.empty? + payload["iss"] = Authly.config.issuer + payload["aud"] = client_id + payload + end + + private def self.resolve_subject(owner_id : String) : String + Authly.owners.id_token(owner_id)["sub"].to_s + rescue + owner_id + end + + private def self.validate_oauth_client!( + client_id : String, + redirect_uri : String, + scope : String, + response_type : String, + ) : Nil + raise Error.invalid_client unless Authly.clients.valid_client?(client_id) + unless redirect_uri.empty? + raise Error.invalid_redirect_uri unless Authly.clients.valid_redirect?(client_id, redirect_uri) + end + unless scope.empty? + raise Error.invalid_scope unless Authly.clients.allowed_scopes?(client_id, scope) + end + raise Error.invalid_response_type unless ["code", "token"].includes?(response_type) + end + + private def self.redirect_with_params( + context : HTTP::Server::Context, + redirect_uri : String, + params : Hash(String, String), + fragment : Bool, + ) : Nil + location = URI.parse(redirect_uri) + encoded = URI::Params.build do |form| + params.each { |k, v| form.add(k, v) unless v.empty? } + end + + if fragment + location.fragment = encoded + else + location.query = [location.query.presence, encoded].compact.join("&") + end + ResponseHelper.redirect(context, location.to_s) + end + + private def self.extract_callback_params(context : HTTP::Server::Context) : Hash(String, String) + result = {} of String => String + context.request.query_params.each { |k, v| result[k] = v } + if context.request.method == "POST" + context.request.form_params.each { |k, v| result[k] = v } + end + result + end + + private def self.pkce_s256_challenge(verifier : String) : String + digest = OpenSSL::Digest.new("SHA256") + digest.update(verifier) + Base64.urlsafe_encode(digest.final, padding: false) + end + end +end diff --git a/src/authly/owner.cr b/src/authly/owner.cr index 561123c..e401f58 100644 --- a/src/authly/owner.cr +++ b/src/authly/owner.cr @@ -3,9 +3,11 @@ module Authly property id : String = Random::Secure.hex(16) property username : String property password : String + property email : String? = nil property public_key : String? = nil property secret_key : String? = nil property key_id : String? = nil + property social_identities : Hash(String, String) = {} of String => String def initialize( @username : String, @@ -14,6 +16,8 @@ module Authly @secret_key : String? = nil, @key_id : String? = nil, @id : String = Random::Secure.hex(16), + @email : String? = nil, + @social_identities : Hash(String, String) = {} of String => String, ) end end @@ -72,20 +76,32 @@ module Authly def find_owner(identifier : String) : Owner? find do |owner| - owner.username == identifier || owner.id == identifier || owner.key_id == identifier + owner.username == identifier || owner.id == identifier || owner.key_id == identifier || owner.email == identifier end end - def id_token(user_id : String) : Hash(String, String | Int64) - user = find! { |owner| owner.username == user_id || owner.id == user_id || owner.key_id == user_id } - { + def resolve_social_owner(profile : SocialProfile) : String? + matched = find do |owner| + owner.social_identities[profile.provider]? == profile.uid || + (profile.email_verified? && !profile.email.to_s.empty? && + (owner.email == profile.email || owner.username == profile.email)) + end + matched.try(&.id) + end + def id_token(user_id : String) : Hash(String, String | Int64) + user = find! { |owner| owner.username == user_id || owner.id == user_id || owner.key_id == user_id || owner.email == user_id } + claims = { "user_id" => user.username, "sub" => user.id, "iat" => Time.utc.to_unix, "exp" => Authly.config.access_ttl.from_now.to_unix, "iss" => Authly.config.issuer, } + if email = user.email.presence + claims["email"] = email + end + claims end def each(& : Owner -> _) diff --git a/src/authly/social/http_client.cr b/src/authly/social/http_client.cr new file mode 100644 index 0000000..51ff5ff --- /dev/null +++ b/src/authly/social/http_client.cr @@ -0,0 +1,96 @@ +require "http/client" +require "uri" + +module Authly + module Social + record HttpResponse, status_code : Int32, body : String, headers : HTTP::Headers = HTTP::Headers.new + + module HttpClient + abstract def post_form( + url : String, + params : Hash(String, String), + headers : HTTP::Headers = HTTP::Headers.new, + ) : HttpResponse + + abstract def get( + url : String, + headers : HTTP::Headers = HTTP::Headers.new, + ) : HttpResponse + end + + class StdlibHttpClient + include HttpClient + + MAX_BODY_BYTES = 1_048_576 + + property connect_timeout : Time::Span + property read_timeout : Time::Span + + def initialize( + @connect_timeout : Time::Span = 10.seconds, + @read_timeout : Time::Span = 10.seconds, + ) + end + + def post_form( + url : String, + params : Hash(String, String), + headers : HTTP::Headers = HTTP::Headers.new, + ) : HttpResponse + uri = URI.parse(url) + req_headers = headers.dup + req_headers["Content-Type"] ||= "application/x-www-form-urlencoded" + req_headers["Accept"] ||= "application/json" + req_headers["User-Agent"] ||= "Authly-SocialClient/1.0" + body = URI::Params.encode(params) + + with_client(uri) do |client| + path = uri.request_target + client.post(path, headers: req_headers, body: body) do |response| + read_bounded_response(response) + end + end + rescue IO::Error | Socket::Error + raise Error.invalid_grant + end + + def get( + url : String, + headers : HTTP::Headers = HTTP::Headers.new, + ) : HttpResponse + uri = URI.parse(url) + req_headers = headers.dup + req_headers["Accept"] ||= "application/json" + req_headers["User-Agent"] ||= "Authly-SocialClient/1.0" + + with_client(uri) do |client| + path = uri.request_target + client.get(path, headers: req_headers) do |response| + read_bounded_response(response) + end + end + rescue IO::Error | Socket::Error + raise Error.invalid_grant + end + + private def with_client(uri : URI, & : HTTP::Client -> HttpResponse) : HttpResponse + client = HTTP::Client.new(uri) + client.connect_timeout = @connect_timeout + client.read_timeout = @read_timeout + begin + yield client + ensure + client.close + end + end + + private def read_bounded_response(response : HTTP::Client::Response) : HttpResponse + io = response.body_io + buffer = IO::Memory.new + bytes_copied = IO.copy(io, buffer, MAX_BODY_BYTES + 1) + raise Error.invalid_grant if bytes_copied > MAX_BODY_BYTES + HttpResponse.new(response.status_code, buffer.to_s, response.headers) + end + end + end +end diff --git a/src/authly/social/provider.cr b/src/authly/social/provider.cr new file mode 100644 index 0000000..8fbf205 --- /dev/null +++ b/src/authly/social/provider.cr @@ -0,0 +1,200 @@ +require "base64" +require "json" +require "uri" +require "./social_profile" +require "./http_client" + +module Authly + module Social + abstract class Provider + getter name : String + getter client_id : String + getter client_secret : String + getter redirect_uri : String + getter scopes : Array(String) + + def initialize( + @name : String, + @client_id : String, + @client_secret : String, + @redirect_uri : String, + @scopes : Array(String), + ) + end + + abstract def authorize_endpoint : String + abstract def token_endpoint : String + abstract def userinfo_endpoint : String + + def authorize_url( + state : String, + code_challenge : String? = nil, + code_challenge_method : String? = "S256", + nonce : String? = nil, + redirect_uri : String? = nil, + scope : String? = nil, + extra_params : Hash(String, String) = {} of String => String, + ) : String + uri = URI.parse(authorize_endpoint) + effective_redirect = redirect_uri.presence || @redirect_uri + effective_scope = scope.presence || @scopes.join(" ") + + params = URI::Params.build do |form| + form.add("response_type", "code") + form.add("client_id", @client_id) + form.add("redirect_uri", effective_redirect) unless effective_redirect.empty? + form.add("scope", effective_scope) unless effective_scope.empty? + form.add("state", state) + if challenge = code_challenge.presence + form.add("code_challenge", challenge) + form.add("code_challenge_method", code_challenge_method.presence || "S256") + end + if n = nonce.presence + form.add("nonce", n) + end + default_authorize_params.each do |k, v| + form.add(k, v) unless extra_params.has_key?(k) + end + extra_params.each do |k, v| + form.add(k, v) + end + end + + uri.query = [uri.query.presence, params].compact.join("&") + uri.to_s + end + + def exchange_code( + code : String, + redirect_uri : String? = nil, + code_verifier : String? = nil, + extra_params : Hash(String, String) = {} of String => String, + ) : SocialProfile + raise Error.invalid_request if code.empty? + + form = { + "grant_type" => "authorization_code", + "code" => code, + "client_id" => @client_id, + "client_secret" => @client_secret, + "redirect_uri" => redirect_uri.presence || @redirect_uri, + } + if verifier = code_verifier.presence + form["code_verifier"] = verifier + end + extra_params.each { |k, v| form[k] = v } + + headers = HTTP::Headers{"Accept" => "application/json"} + response = http_client.post_form(token_endpoint, form, headers) + raise Error.invalid_grant unless (200..299).includes?(response.status_code) + + token_data = parse_json_object(response.body) + raise Error.invalid_grant if token_data.has_key?("error") + + access_token = token_data["access_token"]?.try(&.as_s?) + id_token = token_data["id_token"]?.try(&.as_s?) + refresh_token = token_data["refresh_token"]?.try(&.as_s?) + expires_in = token_data["expires_in"]?.try(&.as_i64?) + + build_profile( + access_token: access_token, + id_token: id_token, + refresh_token: refresh_token, + expires_in: expires_in, + extra_params: extra_params, + ) + end + + def verify_token( + access_token : String? = nil, + id_token : String? = nil, + extra_params : Hash(String, String) = {} of String => String, + ) : SocialProfile + raise Error.invalid_request if access_token.to_s.empty? && id_token.to_s.empty? + + build_profile( + access_token: access_token.presence, + id_token: id_token.presence, + refresh_token: nil, + expires_in: nil, + extra_params: extra_params, + ) + end + + protected def default_authorize_params : Hash(String, String) + {} of String => String + end + + protected abstract def build_profile( + access_token : String?, + id_token : String?, + refresh_token : String?, + expires_in : Int64?, + extra_params : Hash(String, String), + ) : SocialProfile + + protected def fetch_userinfo_json(access_token : String, url : String = userinfo_endpoint) : Hash(String, JSON::Any) + headers = HTTP::Headers{ + "Authorization" => "Bearer #{access_token}", + "Accept" => "application/json", + } + response = http_client.get(url, headers) + raise Error.invalid_grant unless (200..299).includes?(response.status_code) + parse_json_object(response.body) + end + + protected def parse_json_object(body : String) : Hash(String, JSON::Any) + JSON.parse(body).as_h + rescue JSON::ParseException | TypeCastError + raise Error.invalid_grant + end + + protected def decode_jwt_payload(jwt_token : String) : Hash(String, JSON::Any) + parts = jwt_token.split('.') + raise Error.invalid_grant unless parts.size == 3 + decoded = String.new(Base64.decode(parts[1])) + parse_json_object(decoded) + rescue Base64::Error | ArgumentError + raise Error.invalid_grant + end + + protected def verify_id_token_claims!( + claims : Hash(String, JSON::Any), + expected_issuers : Array(String), + ) : Nil + iss = claims["iss"]?.try(&.as_s?) || "" + raise Error.invalid_grant unless expected_issuers.includes?(iss) + + aud_claim = claims["aud"]? + aud_matches = + if s = aud_claim.try(&.as_s?) + s == @client_id + elsif arr = aud_claim.try(&.as_a?) + arr.any? { |item| item.as_s? == @client_id } + else + false + end + raise Error.invalid_grant unless aud_matches + + exp = claims["exp"]?.try(&.as_i64?) + raise Error.invalid_grant if exp.nil? || exp <= Time.utc.to_unix + end + + protected def bool_claim(claims : Hash(String, JSON::Any), key : String) : Bool + val = claims[key]? + return false unless val + if !(b = val.as_bool?).nil? + b + elsif s = val.as_s? + s.downcase == "true" + else + false + end + end + + protected def http_client : HttpClient + Authly.config.social_http_client + end + end + end +end diff --git a/src/authly/social/providers/apple.cr b/src/authly/social/providers/apple.cr new file mode 100644 index 0000000..692ab3e --- /dev/null +++ b/src/authly/social/providers/apple.cr @@ -0,0 +1,90 @@ +require "../provider" + +module Authly + module Social + class Apple < Provider + AUTHORIZE_URL = "https://appleid.apple.com/auth/authorize" + TOKEN_URL = "https://appleid.apple.com/auth/token" + ISSUERS = ["https://appleid.apple.com"] + + def initialize( + client_id : String, + client_secret : String, + redirect_uri : String, + scopes : Array(String) = ["name", "email"], + name : String = "apple", + ) + super(name, client_id, client_secret, redirect_uri, scopes) + end + + def authorize_endpoint : String + AUTHORIZE_URL + end + + def token_endpoint : String + TOKEN_URL + end + + def userinfo_endpoint : String + "" + end + + protected def default_authorize_params : Hash(String, String) + return {} of String => String if @scopes.empty? + {"response_mode" => "form_post"} + end + + protected def build_profile( + access_token : String?, + id_token : String?, + refresh_token : String?, + expires_in : Int64?, + extra_params : Hash(String, String), + ) : SocialProfile + jwt = id_token.presence + raise Error.invalid_grant unless jwt + + claims = decode_jwt_payload(jwt) + verify_id_token_claims!(claims, ISSUERS) + + uid = claims["sub"]?.try(&.as_s?).presence + raise Error.invalid_grant unless uid + + first_name, last_name, full_name, user_email = parse_apple_user_json(extra_params["user"]?) + email = claims["email"]?.try(&.as_s?).presence || user_email + + SocialProfile.new( + provider: @name, + uid: uid, + email: email, + email_verified: bool_claim(claims, "email_verified"), + name: full_name, + first_name: first_name, + last_name: last_name, + access_token: access_token, + id_token: id_token, + refresh_token: refresh_token, + expires_in: expires_in, + raw_attributes: claims, + ) + end + + private def parse_apple_user_json(user_json : String?) : Tuple(String?, String?, String?, String?) + return {nil, nil, nil, nil} if user_json.nil? || user_json.empty? + + data = JSON.parse(user_json).as_h? + return {nil, nil, nil, nil} unless data + + name_obj = data["name"]?.try(&.as_h?) + first = name_obj.try(&.["firstName"]?.try(&.as_s?)).presence + last = name_obj.try(&.["lastName"]?.try(&.as_s?)).presence + full = [first, last].compact.join(" ").presence + email = data["email"]?.try(&.as_s?).presence + + {first, last, full, email} + rescue JSON::ParseException | TypeCastError + {nil, nil, nil, nil} + end + end + end +end diff --git a/src/authly/social/providers/discord.cr b/src/authly/social/providers/discord.cr new file mode 100644 index 0000000..42b6532 --- /dev/null +++ b/src/authly/social/providers/discord.cr @@ -0,0 +1,68 @@ +require "../provider" + +module Authly + module Social + class Discord < Provider + AUTHORIZE_URL = "https://discord.com/api/oauth2/authorize" + TOKEN_URL = "https://discord.com/api/oauth2/token" + USERINFO_URL = "https://discord.com/api/users/@me" + + def initialize( + client_id : String, + client_secret : String, + redirect_uri : String, + scopes : Array(String) = ["identify", "email"], + name : String = "discord", + ) + super(name, client_id, client_secret, redirect_uri, scopes) + end + + def authorize_endpoint : String + AUTHORIZE_URL + end + + def token_endpoint : String + TOKEN_URL + end + + def userinfo_endpoint : String + USERINFO_URL + end + + protected def build_profile( + access_token : String?, + id_token : String?, + refresh_token : String?, + expires_in : Int64?, + extra_params : Hash(String, String), + ) : SocialProfile + token = access_token.presence + raise Error.invalid_grant unless token + + claims = fetch_userinfo_json(token) + uid = claims["id"]?.try(&.as_s?).presence + raise Error.invalid_grant unless uid + + username = claims["username"]?.try(&.as_s?) + global_name = claims["global_name"]?.try(&.as_s?).presence || username + avatar_hash = claims["avatar"]?.try(&.as_s?).presence + avatar_url = avatar_hash ? "https://cdn.discordapp.com/avatars/#{uid}/#{avatar_hash}.png" : nil + + SocialProfile.new( + provider: @name, + uid: uid, + email: claims["email"]?.try(&.as_s?), + email_verified: bool_claim(claims, "verified"), + name: global_name, + nickname: username, + avatar_url: avatar_url, + access_token: access_token, + id_token: id_token, + refresh_token: refresh_token, + expires_in: expires_in, + raw_attributes: claims, + ) + end + end + end +end diff --git a/src/authly/social/providers/generic_oauth2.cr b/src/authly/social/providers/generic_oauth2.cr new file mode 100644 index 0000000..aabe33a --- /dev/null +++ b/src/authly/social/providers/generic_oauth2.cr @@ -0,0 +1,72 @@ +require "../provider" + +module Authly + module Social + class GenericOAuth2 < Provider + getter authorize_endpoint : String + getter token_endpoint : String + getter userinfo_endpoint : String + getter uid_field : String + getter email_field : String + getter email_verified_field : String + getter name_field : String + getter avatar_field : String + + def initialize( + name : String, + client_id : String, + client_secret : String, + redirect_uri : String, + @authorize_endpoint : String, + @token_endpoint : String, + @userinfo_endpoint : String, + scopes : Array(String) = ["openid", "profile", "email"], + @uid_field : String = "sub", + @email_field : String = "email", + @email_verified_field : String = "email_verified", + @name_field : String = "name", + @avatar_field : String = "picture", + ) + super(name, client_id, client_secret, redirect_uri, scopes) + end + + protected def build_profile( + access_token : String?, + id_token : String?, + refresh_token : String?, + expires_in : Int64?, + extra_params : Hash(String, String), + ) : SocialProfile + claims = + if token = access_token.presence + fetch_userinfo_json(token) + elsif jwt = id_token.presence + decode_jwt_payload(jwt) + else + raise Error.invalid_grant + end + + raw_uid = claims[@uid_field]? || claims["sub"]? || claims["id"]? + uid = (raw_uid.try(&.as_s?) || raw_uid.try(&.as_i64?).try(&.to_s)).presence + raise Error.invalid_grant unless uid + + SocialProfile.new( + provider: @name, + uid: uid, + email: claims[@email_field]?.try(&.as_s?), + email_verified: bool_claim(claims, @email_verified_field), + name: claims[@name_field]?.try(&.as_s?), + first_name: claims["given_name"]?.try(&.as_s?), + last_name: claims["family_name"]?.try(&.as_s?), + nickname: claims["nickname"]?.try(&.as_s?) || claims["preferred_username"]?.try(&.as_s?), + avatar_url: claims[@avatar_field]?.try(&.as_s?), + access_token: access_token, + id_token: id_token, + refresh_token: refresh_token, + expires_in: expires_in, + raw_attributes: claims, + ) + end + end + end +end diff --git a/src/authly/social/providers/github.cr b/src/authly/social/providers/github.cr new file mode 100644 index 0000000..3a668f9 --- /dev/null +++ b/src/authly/social/providers/github.cr @@ -0,0 +1,100 @@ +require "../provider" + +module Authly + module Social + class GitHub < Provider + AUTHORIZE_URL = "https://github.com/login/oauth/authorize" + TOKEN_URL = "https://github.com/login/oauth/access_token" + USERINFO_URL = "https://api.github.com/user" + EMAILS_URL = "https://api.github.com/user/emails" + + def initialize( + client_id : String, + client_secret : String, + redirect_uri : String, + scopes : Array(String) = ["read:user", "user:email"], + name : String = "github", + ) + super(name, client_id, client_secret, redirect_uri, scopes) + end + + def authorize_endpoint : String + AUTHORIZE_URL + end + + def token_endpoint : String + TOKEN_URL + end + + def userinfo_endpoint : String + USERINFO_URL + end + + protected def build_profile( + access_token : String?, + id_token : String?, + refresh_token : String?, + expires_in : Int64?, + extra_params : Hash(String, String), + ) : SocialProfile + token = access_token.presence + raise Error.invalid_grant unless token + + claims = fetch_userinfo_json(token) + uid = (claims["id"]?.try(&.as_i64?).try(&.to_s) || claims["id"]?.try(&.as_s?)).presence + raise Error.invalid_grant unless uid + + email = claims["email"]?.try(&.as_s?).presence + email_verified = false + + if @scopes.includes?("user:email") + resolved_email, verified = fetch_primary_email(token, email) + email ||= resolved_email + email_verified = verified + end + + SocialProfile.new( + provider: @name, + uid: uid, + email: email, + email_verified: email_verified, + name: claims["name"]?.try(&.as_s?), + nickname: claims["login"]?.try(&.as_s?), + avatar_url: claims["avatar_url"]?.try(&.as_s?), + access_token: access_token, + id_token: id_token, + refresh_token: refresh_token, + expires_in: expires_in, + raw_attributes: claims, + ) + end + + private def fetch_primary_email(access_token : String, current_email : String?) : Tuple(String?, Bool) + headers = HTTP::Headers{ + "Authorization" => "Bearer #{access_token}", + "Accept" => "application/json", + } + response = http_client.get(EMAILS_URL, headers) + return {current_email, false} unless (200..299).includes?(response.status_code) + + entries = JSON.parse(response.body).as_a? + return {current_email, false} unless entries + + primary_entry = entries.find do |item| + obj = item.as_h? + obj && obj["primary"]?.try(&.as_bool?) == true + end || entries.first? + + if obj = primary_entry.try(&.as_h?) + addr = obj["email"]?.try(&.as_s?).presence || current_email + verified = obj["verified"]?.try(&.as_bool?) || false + {addr, verified} + else + {current_email, false} + end + rescue JSON::ParseException | TypeCastError + {current_email, false} + end + end + end +end diff --git a/src/authly/social/providers/google.cr b/src/authly/social/providers/google.cr new file mode 100644 index 0000000..042551c --- /dev/null +++ b/src/authly/social/providers/google.cr @@ -0,0 +1,72 @@ +require "../provider" + +module Authly + module Social + class Google < Provider + AUTHORIZE_URL = "https://accounts.google.com/o/oauth2/v2/auth" + TOKEN_URL = "https://oauth2.googleapis.com/token" + USERINFO_URL = "https://openidconnect.googleapis.com/v1/userinfo" + ISSUERS = ["https://accounts.google.com", "accounts.google.com"] + + def initialize( + client_id : String, + client_secret : String, + redirect_uri : String, + scopes : Array(String) = ["openid", "email", "profile"], + name : String = "google", + ) + super(name, client_id, client_secret, redirect_uri, scopes) + end + + def authorize_endpoint : String + AUTHORIZE_URL + end + + def token_endpoint : String + TOKEN_URL + end + + def userinfo_endpoint : String + USERINFO_URL + end + + protected def build_profile( + access_token : String?, + id_token : String?, + refresh_token : String?, + expires_in : Int64?, + extra_params : Hash(String, String), + ) : SocialProfile + claims = + if token = access_token.presence + fetch_userinfo_json(token) + elsif jwt = id_token.presence + decoded = decode_jwt_payload(jwt) + verify_id_token_claims!(decoded, ISSUERS) + decoded + else + raise Error.invalid_grant + end + + uid = claims["sub"]?.try(&.as_s?).presence || claims["id"]?.try(&.as_s?).presence + raise Error.invalid_grant unless uid + + SocialProfile.new( + provider: @name, + uid: uid, + email: claims["email"]?.try(&.as_s?), + email_verified: bool_claim(claims, "email_verified"), + name: claims["name"]?.try(&.as_s?), + first_name: claims["given_name"]?.try(&.as_s?), + last_name: claims["family_name"]?.try(&.as_s?), + avatar_url: claims["picture"]?.try(&.as_s?), + access_token: access_token, + id_token: id_token, + refresh_token: refresh_token, + expires_in: expires_in, + raw_attributes: claims, + ) + end + end + end +end diff --git a/src/authly/social/providers/microsoft.cr b/src/authly/social/providers/microsoft.cr new file mode 100644 index 0000000..fc0d8ed --- /dev/null +++ b/src/authly/social/providers/microsoft.cr @@ -0,0 +1,73 @@ +require "../provider" + +module Authly + module Social + class Microsoft < Provider + USERINFO_URL = "https://graph.microsoft.com/oidc/userinfo" + + getter tenant : String + + def initialize( + client_id : String, + client_secret : String, + redirect_uri : String, + @tenant : String = "common", + scopes : Array(String) = ["openid", "profile", "email", "User.Read"], + name : String = "microsoft", + ) + super(name, client_id, client_secret, redirect_uri, scopes) + end + + def authorize_endpoint : String + "https://login.microsoftonline.com/#{@tenant}/oauth2/v2.0/authorize" + end + + def token_endpoint : String + "https://login.microsoftonline.com/#{@tenant}/oauth2/v2.0/token" + end + + def userinfo_endpoint : String + USERINFO_URL + end + + protected def build_profile( + access_token : String?, + id_token : String?, + refresh_token : String?, + expires_in : Int64?, + extra_params : Hash(String, String), + ) : SocialProfile + claims = + if token = access_token.presence + fetch_userinfo_json(token) + elsif jwt = id_token.presence + decode_jwt_payload(jwt) + else + raise Error.invalid_grant + end + + uid = (claims["sub"]?.try(&.as_s?) || claims["oid"]?.try(&.as_s?) || claims["id"]?.try(&.as_s?)).presence + raise Error.invalid_grant unless uid + + email = (claims["email"]?.try(&.as_s?) || claims["preferred_username"]?.try(&.as_s?)).presence + verified = claims.has_key?("email_verified") ? bool_claim(claims, "email_verified") : !email.nil? + + SocialProfile.new( + provider: @name, + uid: uid, + email: email, + email_verified: verified, + name: claims["name"]?.try(&.as_s?), + first_name: claims["given_name"]?.try(&.as_s?), + last_name: claims["family_name"]?.try(&.as_s?), + avatar_url: claims["picture"]?.try(&.as_s?), + access_token: access_token, + id_token: id_token, + refresh_token: refresh_token, + expires_in: expires_in, + raw_attributes: claims, + ) + end + end + end +end diff --git a/src/authly/social/social_profile.cr b/src/authly/social/social_profile.cr new file mode 100644 index 0000000..fbcae31 --- /dev/null +++ b/src/authly/social/social_profile.cr @@ -0,0 +1,40 @@ +require "json" + +module Authly + struct SocialProfile + include JSON::Serializable + + property provider : String + property uid : String + property email : String? = nil + property? email_verified : Bool = false + property name : String? = nil + property first_name : String? = nil + property last_name : String? = nil + property nickname : String? = nil + property avatar_url : String? = nil + property access_token : String? = nil + property id_token : String? = nil + property refresh_token : String? = nil + property expires_in : Int64? = nil + property raw_attributes : Hash(String, JSON::Any) = {} of String => JSON::Any + + def initialize( + @provider : String, + @uid : String, + @email : String? = nil, + @email_verified : Bool = false, + @name : String? = nil, + @first_name : String? = nil, + @last_name : String? = nil, + @nickname : String? = nil, + @avatar_url : String? = nil, + @access_token : String? = nil, + @id_token : String? = nil, + @refresh_token : String? = nil, + @expires_in : Int64? = nil, + @raw_attributes : Hash(String, JSON::Any) = {} of String => JSON::Any, + ) + end + end +end