diff --git a/vertx-auth-oauth2/src/main/asciidoc/index.adoc b/vertx-auth-oauth2/src/main/asciidoc/index.adoc index 7fb5d9615..c697e7e6b 100644 --- a/vertx-auth-oauth2/src/main/asciidoc/index.adoc +++ b/vertx-auth-oauth2/src/main/asciidoc/index.adoc @@ -129,6 +129,9 @@ A couple of well known OpenID Connect Discovery providers are: * Microsoft: `https://login.windows.net/common` * IBM Cloud: `https://.appid.cloud.ibm.com/oauth/v4/` * Amazon Cognito: `"https://cognito-idp..amazonaws.com/"` +* Okta: `https://` (org authorization server) or `https:///oauth2/` +* Ory (Ory Network / Ory Hydra): `https://.projects.oryapis.com` +* Twitch: `https://id.twitch.tv/oauth2` This and the given `client id/client secret` is enough to configure your auth provider object. @@ -165,6 +168,7 @@ Currently we provide: * Azure Active Directory {@link io.vertx.ext.auth.oauth2.providers.AzureADAuth} * Box.com {@link io.vertx.ext.auth.oauth2.providers.BoxAuth} * CloudFoundry {@link io.vertx.ext.auth.oauth2.providers.CloudFoundryAuth} +* Discord {@link io.vertx.ext.auth.oauth2.providers.DiscordAuth} * Dropbox {@link io.vertx.ext.auth.oauth2.providers.DropboxAuth} * Facebook {@link io.vertx.ext.auth.oauth2.providers.FacebookAuth} * Foursquare {@link io.vertx.ext.auth.oauth2.providers.FoursquareAuth} @@ -178,11 +182,15 @@ Currently we provide: * LinkedIn {@link io.vertx.ext.auth.oauth2.providers.LinkedInAuth} * Live.com {@link io.vertx.ext.auth.oauth2.providers.LiveAuth} * Mailchimp {@link io.vertx.ext.auth.oauth2.providers.MailchimpAuth} +* Okta {@link io.vertx.ext.auth.oauth2.providers.OktaAuth} * OpenIDConnect {@link io.vertx.ext.auth.oauth2.providers.OpenIDConnectAuth} +* Ory {@link io.vertx.ext.auth.oauth2.providers.OryAuth} * Salesforce {@link io.vertx.ext.auth.oauth2.providers.SalesforceAuth} * Shopify {@link io.vertx.ext.auth.oauth2.providers.ShopifyAuth} * Soundcloud {@link io.vertx.ext.auth.oauth2.providers.SoundcloudAuth} +* Spotify {@link io.vertx.ext.auth.oauth2.providers.SpotifyAuth} * Stripe {@link io.vertx.ext.auth.oauth2.providers.StripeAuth} +* Twitch {@link io.vertx.ext.auth.oauth2.providers.TwitchAuth} * Twitter {@link io.vertx.ext.auth.oauth2.providers.TwitterAuth} === JBoss Keycloak diff --git a/vertx-auth-oauth2/src/main/java/io/vertx/ext/auth/oauth2/providers/DiscordAuth.java b/vertx-auth-oauth2/src/main/java/io/vertx/ext/auth/oauth2/providers/DiscordAuth.java new file mode 100644 index 000000000..dc32925cf --- /dev/null +++ b/vertx-auth-oauth2/src/main/java/io/vertx/ext/auth/oauth2/providers/DiscordAuth.java @@ -0,0 +1,72 @@ +/* + * Copyright 2015 Red Hat, Inc. + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * and Apache License v2.0 which accompanies this distribution. + * + * The Eclipse Public License is available at + * http://www.eclipse.org/legal/epl-v10.html + * + * The Apache License v2.0 is available at + * http://www.opensource.org/licenses/apache2.0.php + * + * You may elect to redistribute this code under either of these licenses. + */ +package io.vertx.ext.auth.oauth2.providers; + +import io.vertx.codegen.annotations.VertxGen; +import io.vertx.core.Vertx; +import io.vertx.core.http.HttpClientOptions; +import io.vertx.ext.auth.oauth2.OAuth2Auth; +import io.vertx.ext.auth.oauth2.OAuth2Options; + +/** + * Simplified factory to create an {@link OAuth2Auth} for Discord. + *

+ * The OAuth2 endpoints are documented at + * https://docs.discord.com/developers/topics/oauth2. + *

+ * The user info endpoint is {@code GET /users/@me} + * (https://docs.discord.com/developers/resources/user#get-current-user) + * and requires the {@code identify} scope. It returns a user object with, among others, {@code id}, + * {@code username}, {@code discriminator}, {@code global_name}, {@code avatar}, {@code banner}, + * {@code accent_color}, {@code locale}, {@code mfa_enabled}, {@code flags}, {@code public_flags}, and + * {@code email}, {@code verified} when the {@code email} scope is also granted. + */ +@VertxGen +public interface DiscordAuth { + + /** + * Create a OAuth2Auth provider for Discord + * + * @param clientId the client id given to you by Discord + * @param clientSecret the client secret given to you by Discord + */ + static OAuth2Auth create(Vertx vertx, String clientId, String clientSecret) { + return create(vertx, clientId, clientSecret, new HttpClientOptions()); + } + + /** + * Create a OAuth2Auth provider for Discord + * + * @param clientId the client id given to you by Discord + * @param clientSecret the client secret given to you by Discord + * @param httpClientOptions custom http client options + */ + static OAuth2Auth create(Vertx vertx, String clientId, String clientSecret, HttpClientOptions httpClientOptions) { + return + OAuth2Auth.create(vertx, new OAuth2Options() + .setHttpClientOptions(httpClientOptions) + .setClientId(clientId) + .setClientSecret(clientSecret) + .setSite("https://discord.com/api") + // https://docs.discord.com/developers/topics/oauth2#shared-resources-oauth2-urls + .setAuthorizationPath("https://discord.com/oauth2/authorize") + .setTokenPath("/oauth2/token") + .setRevocationPath("/oauth2/token/revoke") + // https://docs.discord.com/developers/resources/user#get-current-user + .setUserInfoPath("/users/@me") + .setScopeSeparator(" ")); + } +} diff --git a/vertx-auth-oauth2/src/main/java/io/vertx/ext/auth/oauth2/providers/OktaAuth.java b/vertx-auth-oauth2/src/main/java/io/vertx/ext/auth/oauth2/providers/OktaAuth.java new file mode 100644 index 000000000..fcc4de0bd --- /dev/null +++ b/vertx-auth-oauth2/src/main/java/io/vertx/ext/auth/oauth2/providers/OktaAuth.java @@ -0,0 +1,155 @@ +/* + * Copyright 2015 Red Hat, Inc. + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * and Apache License v2.0 which accompanies this distribution. + * + * The Eclipse Public License is available at + * http://www.eclipse.org/legal/epl-v10.html + * + * The Apache License v2.0 is available at + * http://www.opensource.org/licenses/apache2.0.php + * + * You may elect to redistribute this code under either of these licenses. + */ +package io.vertx.ext.auth.oauth2.providers; + +import io.vertx.codegen.annotations.VertxGen; +import io.vertx.core.Future; +import io.vertx.core.Vertx; +import io.vertx.core.http.HttpClientOptions; +import io.vertx.ext.auth.oauth2.OAuth2Auth; +import io.vertx.ext.auth.oauth2.OAuth2Options; + +/** + * Simplified factory to create an {@link OAuth2Auth} for Okta. + *

+ * Okta exposes two kinds of OpenID Connect authorization servers + * (https://developer.okta.com/docs/concepts/auth-servers/): + *

    + *
  • the org authorization server: {@code https://{yourOktaDomain}/oauth2/v1/...} with issuer + * {@code https://{yourOktaDomain}}, used when no authorization server id is given;
  • + *
  • a custom authorization server: {@code https://{yourOktaDomain}/oauth2/{authorizationServerId}/v1/...} + * with issuer {@code https://{yourOktaDomain}/oauth2/{authorizationServerId}} (the pre-configured one is named + * {@code default}).
  • + *
+ * The endpoint paths are documented in the Okta OpenID Connect & OAuth 2.0 API reference: + * https://developer.okta.com/docs/reference/api/oidc/. + *

+ * The {@code /userinfo} endpoint returns the OpenID Connect claims for the granted scopes: {@code sub} always; + * {@code name}, {@code nickname}, {@code preferred_username}, {@code given_name}, {@code middle_name}, + * {@code family_name}, {@code picture}, {@code website}, {@code gender}, {@code birthdate}, {@code zoneinfo}, + * {@code locale}, {@code updated_at} for {@code profile}; {@code email}, {@code email_verified} for {@code email}; + * {@code address} for {@code address}; {@code phone_number} for {@code phone}. + *

+ * Client credentials are sent using HTTP Basic authentication ({@code client_secret_basic}), Okta's default + * token endpoint authentication method + * (https://developer.okta.com/docs/api/openapi/okta-oauth/guides/client-auth/). + */ +@VertxGen +public interface OktaAuth extends OpenIDConnectAuth { + + /** + * Create a OAuth2Auth provider for Okta using the org authorization server. + * + * @param clientId the client id given to you by Okta + * @param clientSecret the client secret given to you by Okta + * @param domain your Okta domain, eg. {@code dev-123456.okta.com} + */ + static OAuth2Auth create(Vertx vertx, String clientId, String clientSecret, String domain) { + return create(vertx, clientId, clientSecret, domain, new HttpClientOptions()); + } + + /** + * Create a OAuth2Auth provider for Okta using the org authorization server. + * + * @param clientId the client id given to you by Okta + * @param clientSecret the client secret given to you by Okta + * @param domain your Okta domain, eg. {@code dev-123456.okta.com} + * @param httpClientOptions custom http client options + */ + static OAuth2Auth create(Vertx vertx, String clientId, String clientSecret, String domain, HttpClientOptions httpClientOptions) { + return + OAuth2Auth.create(vertx, new OAuth2Options() + .setHttpClientOptions(httpClientOptions) + .setClientId(clientId) + .setClientSecret(clientSecret) + .setTenant(domain) + // https://developer.okta.com/docs/concepts/auth-servers/#org-authorization-server + .setSite("https://{tenant}") + // https://developer.okta.com/docs/reference/api/oidc/#endpoints + .setAuthorizationPath("/oauth2/v1/authorize") + .setTokenPath("/oauth2/v1/token") + .setUserInfoPath("/oauth2/v1/userinfo") + // RFC 7009 + .setRevocationPath("/oauth2/v1/revoke") + // RFC 7662 + .setIntrospectionPath("/oauth2/v1/introspect") + // RFC 7517 + .setJwkPath("/oauth2/v1/keys") + .setLogoutPath("/oauth2/v1/logout") + .setScopeSeparator(" ")); + } + + /** + * Create a OAuth2Auth provider for Okta using a custom authorization server. + * + * @param clientId the client id given to you by Okta + * @param clientSecret the client secret given to you by Okta + * @param domain your Okta domain, eg. {@code dev-123456.okta.com} + * @param authorizationServerId the custom authorization server id, eg. {@code default} + */ + static OAuth2Auth create(Vertx vertx, String clientId, String clientSecret, String domain, String authorizationServerId) { + return create(vertx, clientId, clientSecret, domain, authorizationServerId, new HttpClientOptions()); + } + + /** + * Create a OAuth2Auth provider for Okta using a custom authorization server. + * + * @param clientId the client id given to you by Okta + * @param clientSecret the client secret given to you by Okta + * @param domain your Okta domain, eg. {@code dev-123456.okta.com} + * @param authorizationServerId the custom authorization server id, eg. {@code default} + * @param httpClientOptions custom http client options + */ + static OAuth2Auth create(Vertx vertx, String clientId, String clientSecret, String domain, String authorizationServerId, HttpClientOptions httpClientOptions) { + return + OAuth2Auth.create(vertx, new OAuth2Options() + .setHttpClientOptions(httpClientOptions) + .setClientId(clientId) + .setClientSecret(clientSecret) + .setTenant(domain) + // https://developer.okta.com/docs/concepts/auth-servers/#custom-authorization-server + .setSite("https://{tenant}/oauth2/" + authorizationServerId) + // https://developer.okta.com/docs/reference/api/oidc/#endpoints + .setAuthorizationPath("/v1/authorize") + .setTokenPath("/v1/token") + .setUserInfoPath("/v1/userinfo") + // RFC 7009 + .setRevocationPath("/v1/revoke") + // RFC 7662 + .setIntrospectionPath("/v1/introspect") + // RFC 7517 + .setJwkPath("/v1/keys") + .setLogoutPath("/v1/logout") + .setScopeSeparator(" ")); + } + + /** + * Create a OAuth2Auth provider for OpenID Connect Discovery. The discovery will use the site in the + * configuration options and attempt to load the well known descriptor. The site is the issuer of the + * authorization server, either {@code https://{yourOktaDomain}} (org authorization server) or + * {@code https://{yourOktaDomain}/oauth2/{authorizationServerId}} (custom authorization server). + *

+ * If the discovered config includes a json web key url, it will be also fetched and the JWKs will be loaded + * into the OAuth provider so tokens can be decoded. + * + * @param vertx the vertx instance + * @param config the initial config + * @return future with instantiated Oauth2 provider instance handler + */ + static Future discover(final Vertx vertx, final OAuth2Options config) { + return OpenIDConnectAuth.discover(vertx, config); + } +} diff --git a/vertx-auth-oauth2/src/main/java/io/vertx/ext/auth/oauth2/providers/OryAuth.java b/vertx-auth-oauth2/src/main/java/io/vertx/ext/auth/oauth2/providers/OryAuth.java new file mode 100644 index 000000000..bf067cbc5 --- /dev/null +++ b/vertx-auth-oauth2/src/main/java/io/vertx/ext/auth/oauth2/providers/OryAuth.java @@ -0,0 +1,108 @@ +/* + * Copyright 2015 Red Hat, Inc. + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * and Apache License v2.0 which accompanies this distribution. + * + * The Eclipse Public License is available at + * http://www.eclipse.org/legal/epl-v10.html + * + * The Apache License v2.0 is available at + * http://www.opensource.org/licenses/apache2.0.php + * + * You may elect to redistribute this code under either of these licenses. + */ +package io.vertx.ext.auth.oauth2.providers; + +import io.vertx.codegen.annotations.VertxGen; +import io.vertx.core.Future; +import io.vertx.core.Vertx; +import io.vertx.core.http.HttpClientOptions; +import io.vertx.ext.auth.oauth2.OAuth2Auth; +import io.vertx.ext.auth.oauth2.OAuth2Options; + +/** + * Simplified factory to create an {@link OAuth2Auth} for Ory (Ory Network or a self hosted Ory Hydra). + *

+ * Ory Hydra is a certified OpenID Connect provider. The endpoints below are taken from the Ory Hydra public API + * reference (https://www.ory.com/docs/hydra/reference/api) + * and the Ory OAuth2 authorization code flow guide + * (https://www.ory.com/docs/oauth2-oidc/authorization-code-flow). + *

+ * The {@code site} is the base URL of the public API of your deployment: + *

    + *
  • Ory Network: {@code https://{project-slug}.projects.oryapis.com}
  • + *
  • Self hosted Ory Hydra: the public port of Hydra, e.g.: {@code http://localhost:4444}
  • + *
+ *

+ * The {@code /userinfo} endpoint returns the standard OpenID Connect claims for the granted scopes + * ({@code sub} always, plus e.g. {@code email}, {@code email_verified}, {@code name}, {@code preferred_username} + * as populated by your consent app). + *

+ * Notes: + *

    + *
  • Token introspection lives on the Ory Hydra admin API ({@code /admin/oauth2/introspect}) which is + * not reachable with client credentials, so no introspection path is configured.
  • + *
  • Client credentials are sent using HTTP Basic authentication ({@code client_secret_basic}), which is the + * default {@code token_endpoint_auth_method} for Ory OAuth2 clients.
  • + *
+ */ +@VertxGen +public interface OryAuth extends OpenIDConnectAuth { + + /** + * Create a OAuth2Auth provider for Ory + * + * @param site root URL for the provider without trailing slashes, eg. https://{project-slug}.projects.oryapis.com + * @param clientId the client id given to you by Ory + * @param clientSecret the client secret given to you by Ory + */ + static OAuth2Auth create(Vertx vertx, String site, String clientId, String clientSecret) { + return create(vertx, site, clientId, clientSecret, new HttpClientOptions()); + } + + /** + * Create a OAuth2Auth provider for Ory + * + * @param site root URL for the provider without trailing slashes, eg. https://{project-slug}.projects.oryapis.com + * @param clientId the client id given to you by Ory + * @param clientSecret the client secret given to you by Ory + * @param httpClientOptions custom http client options + */ + static OAuth2Auth create(Vertx vertx, String site, String clientId, String clientSecret, HttpClientOptions httpClientOptions) { + return + OAuth2Auth.create(vertx, new OAuth2Options() + .setHttpClientOptions(httpClientOptions) + .setClientId(clientId) + .setClientSecret(clientSecret) + .setSite(site) + // https://www.ory.com/docs/hydra/reference/api (public endpoints) + .setAuthorizationPath("/oauth2/auth") + .setTokenPath("/oauth2/token") + .setUserInfoPath("/userinfo") + // RFC 7009 + .setRevocationPath("/oauth2/revoke") + // OpenID Connect RP-Initiated Logout + .setLogoutPath("/oauth2/sessions/logout") + // RFC 7517 + .setJwkPath("/.well-known/jwks.json") + .setScopeSeparator(" ")); + } + + /** + * Create a OAuth2Auth provider for OpenID Connect Discovery. The discovery will use the given site in the + * configuration options and attempt to load the well known descriptor + * ({@code {site}/.well-known/openid-configuration}). + *

+ * If the discovered config includes a json web key url, it will be also fetched and the JWKs will be loaded + * into the OAuth provider so tokens can be decoded. + * + * @param vertx the vertx instance + * @param config the initial config, the site must be set to the Ory public API base URL + * @return future with instantiated Oauth2 provider instance handler + */ + static Future discover(final Vertx vertx, final OAuth2Options config) { + return OpenIDConnectAuth.discover(vertx, config); + } +} diff --git a/vertx-auth-oauth2/src/main/java/io/vertx/ext/auth/oauth2/providers/SpotifyAuth.java b/vertx-auth-oauth2/src/main/java/io/vertx/ext/auth/oauth2/providers/SpotifyAuth.java new file mode 100644 index 000000000..6b9a66529 --- /dev/null +++ b/vertx-auth-oauth2/src/main/java/io/vertx/ext/auth/oauth2/providers/SpotifyAuth.java @@ -0,0 +1,72 @@ +/* + * Copyright 2015 Red Hat, Inc. + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * and Apache License v2.0 which accompanies this distribution. + * + * The Eclipse Public License is available at + * http://www.eclipse.org/legal/epl-v10.html + * + * The Apache License v2.0 is available at + * http://www.opensource.org/licenses/apache2.0.php + * + * You may elect to redistribute this code under either of these licenses. + */ +package io.vertx.ext.auth.oauth2.providers; + +import io.vertx.codegen.annotations.VertxGen; +import io.vertx.core.Vertx; +import io.vertx.core.http.HttpClientOptions; +import io.vertx.ext.auth.oauth2.OAuth2Auth; +import io.vertx.ext.auth.oauth2.OAuth2Options; + +/** + * Simplified factory to create an {@link OAuth2Auth} for Spotify. + *

+ * The OAuth2 endpoints are documented in the Spotify authorization code flow guide + * (https://developer.spotify.com/documentation/web-api/tutorials/code-flow). + * The token endpoint authenticates the client with an HTTP Basic {@code Authorization} header, which is the + * default behaviour of this module. + *

+ * The user info endpoint is {@code GET https://api.spotify.com/v1/me} + * (https://developer.spotify.com/documentation/web-api/reference/get-current-users-profile) + * and returns, among others, {@code id}, {@code display_name}, {@code uri}, {@code href}, {@code images}, + * {@code followers}, {@code external_urls}; {@code country}, {@code product}, {@code explicit_content} require the + * {@code user-read-private} scope and {@code email} requires the {@code user-read-email} scope. + */ +@VertxGen +public interface SpotifyAuth { + + /** + * Create a OAuth2Auth provider for Spotify + * + * @param clientId the client id given to you by Spotify + * @param clientSecret the client secret given to you by Spotify + */ + static OAuth2Auth create(Vertx vertx, String clientId, String clientSecret) { + return create(vertx, clientId, clientSecret, new HttpClientOptions()); + } + + /** + * Create a OAuth2Auth provider for Spotify + * + * @param clientId the client id given to you by Spotify + * @param clientSecret the client secret given to you by Spotify + * @param httpClientOptions custom http client options + */ + static OAuth2Auth create(Vertx vertx, String clientId, String clientSecret, HttpClientOptions httpClientOptions) { + return + OAuth2Auth.create(vertx, new OAuth2Options() + .setHttpClientOptions(httpClientOptions) + .setClientId(clientId) + .setClientSecret(clientSecret) + // https://developer.spotify.com/documentation/web-api/tutorials/code-flow + .setSite("https://accounts.spotify.com") + .setAuthorizationPath("/authorize") + .setTokenPath("/api/token") + // https://developer.spotify.com/documentation/web-api/reference/get-current-users-profile + .setUserInfoPath("https://api.spotify.com/v1/me") + .setScopeSeparator(" ")); + } +} diff --git a/vertx-auth-oauth2/src/main/java/io/vertx/ext/auth/oauth2/providers/TwitchAuth.java b/vertx-auth-oauth2/src/main/java/io/vertx/ext/auth/oauth2/providers/TwitchAuth.java new file mode 100644 index 000000000..da4a1045d --- /dev/null +++ b/vertx-auth-oauth2/src/main/java/io/vertx/ext/auth/oauth2/providers/TwitchAuth.java @@ -0,0 +1,106 @@ +/* + * Copyright 2015 Red Hat, Inc. + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * and Apache License v2.0 which accompanies this distribution. + * + * The Eclipse Public License is available at + * http://www.eclipse.org/legal/epl-v10.html + * + * The Apache License v2.0 is available at + * http://www.opensource.org/licenses/apache2.0.php + * + * You may elect to redistribute this code under either of these licenses. + */ +package io.vertx.ext.auth.oauth2.providers; + +import io.vertx.codegen.annotations.VertxGen; +import io.vertx.core.Future; +import io.vertx.core.Vertx; +import io.vertx.core.http.HttpClientOptions; +import io.vertx.ext.auth.oauth2.OAuth2Auth; +import io.vertx.ext.auth.oauth2.OAuth2Options; + +/** + * Simplified factory to create an {@link OAuth2Auth} for Twitch. + *

+ * Twitch is an OpenID Connect provider, the endpoints are documented at + * https://dev.twitch.tv/docs/authentication/ and + * published in the discovery document {@code https://id.twitch.tv/oauth2/.well-known/openid-configuration}. + *

+ * The {@code /userinfo} endpoint (requires the {@code openid} scope, see + * https://dev.twitch.tv/docs/authentication/getting-tokens-oidc/) + * always returns {@code aud}, {@code exp}, {@code iat}, {@code iss} and {@code sub} (the user id); the claims + * {@code email}, {@code email_verified}, {@code picture}, {@code preferred_username} and {@code updated_at} are + * only returned when requested with the {@code claims} parameter of the authorization request. + *

+ * Twitch expects the client credentials in the body of the token request rather than in an HTTP Basic + * {@code Authorization} header + * (https://dev.twitch.tv/docs/authentication/getting-tokens-oauth/), + * the provider is configured accordingly. + */ +@VertxGen +public interface TwitchAuth extends OpenIDConnectAuth { + + /** + * Create a OAuth2Auth provider for Twitch + * + * @param clientId the client id given to you by Twitch + * @param clientSecret the client secret given to you by Twitch + */ + static OAuth2Auth create(Vertx vertx, String clientId, String clientSecret) { + return create(vertx, clientId, clientSecret, new HttpClientOptions()); + } + + /** + * Create a OAuth2Auth provider for Twitch + * + * @param clientId the client id given to you by Twitch + * @param clientSecret the client secret given to you by Twitch + * @param httpClientOptions custom http client options + */ + static OAuth2Auth create(Vertx vertx, String clientId, String clientSecret, HttpClientOptions httpClientOptions) { + return + OAuth2Auth.create(vertx, new OAuth2Options() + .setHttpClientOptions(httpClientOptions) + .setClientId(clientId) + .setClientSecret(clientSecret) + // issuer, https://id.twitch.tv/oauth2/.well-known/openid-configuration + .setSite("https://id.twitch.tv/oauth2") + .setAuthorizationPath("/authorize") + .setTokenPath("/token") + .setUserInfoPath("/userinfo") + // https://dev.twitch.tv/docs/authentication/revoke-tokens/ + .setRevocationPath("/revoke") + // RFC 7517 + .setJwkPath("/keys") + .setScopeSeparator(" ") + // client credentials must be sent in the request body + .setUseBasicAuthorization(false)); + } + + /** + * Create a OAuth2Auth provider for OpenID Connect Discovery. The discovery will use the default site in the + * configuration options and attempt to load the well known descriptor. If a site is provided (for example when + * running on a custom instance) that site will be used to do the lookup. + *

+ * If the discovered config includes a json web key url, it will be also fetched and the JWKs will be loaded + * into the OAuth provider so tokens can be decoded. + * + * @param vertx the vertx instance + * @param config the initial config + * @return future with instantiated Oauth2 provider instance handler + */ + static Future discover(final Vertx vertx, final OAuth2Options config) { + // don't override if already set + final String site = config.getSite() == null ? "https://id.twitch.tv/oauth2" : config.getSite(); + + return OpenIDConnectAuth.discover( + vertx, + new OAuth2Options(config) + .setSite(site) + // client credentials must be sent in the request body + .setUseBasicAuthorization(false)); + } +} diff --git a/vertx-auth-oauth2/src/test/java/io/vertx/tests/OpenIDCDiscoveryTest.java b/vertx-auth-oauth2/src/test/java/io/vertx/tests/OpenIDCDiscoveryTest.java index 88f1c458e..b2ed6f6ea 100644 --- a/vertx-auth-oauth2/src/test/java/io/vertx/tests/OpenIDCDiscoveryTest.java +++ b/vertx-auth-oauth2/src/test/java/io/vertx/tests/OpenIDCDiscoveryTest.java @@ -140,4 +140,108 @@ public void testApple(TestContext should) { .onFailure(should::fail); } + /** + * Asserts that the endpoints advertised by the provider discovery document match the endpoints hardcoded by the + * provider {@code create()} shortcut. Discovery stores absolute URLs while the shortcuts store the site plus a + * relative path, so relative paths are resolved before comparing. Endpoints omitted from the discovery document + * are not compared. + */ + private static void assertSameEndpoints(TestContext should, OAuth2Options expected, OAuth2Options discovered) { + should.assertEquals(expected.getSite(), discovered.getSite()); + assertSameEndpoint(should, expected, expected.getAuthorizationPath(), discovered.getAuthorizationPath()); + assertSameEndpoint(should, expected, expected.getTokenPath(), discovered.getTokenPath()); + assertSameEndpoint(should, expected, expected.getUserInfoPath(), discovered.getUserInfoPath()); + assertSameEndpoint(should, expected, expected.getRevocationPath(), discovered.getRevocationPath()); + assertSameEndpoint(should, expected, expected.getIntrospectionPath(), discovered.getIntrospectionPath()); + assertSameEndpoint(should, expected, expected.getLogoutPath(), discovered.getLogoutPath()); + assertSameEndpoint(should, expected, expected.getJwkPath(), discovered.getJwkPath()); + } + + private static void assertSameEndpoint(TestContext should, OAuth2Options expected, String expectedPath, String discoveredUrl) { + if (discoveredUrl == null) { + // not advertised by the provider, nothing to compare against + return; + } + should.assertNotNull(expectedPath, "provider advertises " + discoveredUrl + " but the shortcut does not configure it"); + String expectedUrl = expectedPath.startsWith("http") ? expectedPath : expected.getSite() + expectedPath; + should.assertEquals(expectedUrl, discoveredUrl); + } + + @Test + public void testTwitch(TestContext should) { + final Async test = should.async(); + + final OAuth2Options expected = ((OAuth2AuthProviderImpl) TwitchAuth.create(rule.vertx(), "client-id", "client-secret")).getConfig(); + + TwitchAuth.discover(rule.vertx(), new OAuth2Options().setClientId("client-id").setClientSecret("client-secret")) + .onFailure(should::fail) + .onSuccess(oauth2 -> { + OAuth2Options discovered = ((OAuth2AuthProviderImpl) oauth2).getConfig(); + assertSameEndpoints(should, expected, discovered); + // Twitch advertises these endpoints, make sure they were really compared + should.assertNotNull(discovered.getAuthorizationPath()); + should.assertNotNull(discovered.getTokenPath()); + should.assertNotNull(discovered.getUserInfoPath()); + should.assertNotNull(discovered.getJwkPath()); + should.assertEquals("https://id.twitch.tv/oauth2", discovered.getJWTOptions().getIssuer()); + // discover() must keep the Twitch specific client authentication method + should.assertFalse(discovered.isUseBasicAuthorization()); + test.complete(); + }); + } + + @Test + public void testOktaOrgAuthorizationServer(TestContext should) { + final Async test = should.async(); + + // okta.okta.com is Okta's own public org, its discovery documents are reachable without credentials + final OAuth2Options expected = ((OAuth2AuthProviderImpl) OktaAuth.create(rule.vertx(), "client-id", "client-secret", "okta.okta.com")).getConfig(); + + OktaAuth.discover(rule.vertx(), new OAuth2Options() + .setSite("https://okta.okta.com") + .setClientId("client-id") + .setClientSecret("client-secret")) + .onFailure(should::fail) + .onSuccess(oauth2 -> { + OAuth2Options discovered = ((OAuth2AuthProviderImpl) oauth2).getConfig(); + assertSameEndpoints(should, expected, discovered); + // Okta advertises all the endpoints configured by the shortcut, make sure they were really compared + should.assertNotNull(discovered.getAuthorizationPath()); + should.assertNotNull(discovered.getTokenPath()); + should.assertNotNull(discovered.getUserInfoPath()); + should.assertNotNull(discovered.getRevocationPath()); + should.assertNotNull(discovered.getIntrospectionPath()); + should.assertNotNull(discovered.getLogoutPath()); + should.assertNotNull(discovered.getJwkPath()); + should.assertEquals("https://okta.okta.com", discovered.getJWTOptions().getIssuer()); + test.complete(); + }); + } + + @Test + public void testOktaCustomAuthorizationServer(TestContext should) { + final Async test = should.async(); + + final OAuth2Options expected = ((OAuth2AuthProviderImpl) OktaAuth.create(rule.vertx(), "client-id", "client-secret", "okta.okta.com", "default")).getConfig(); + + OktaAuth.discover(rule.vertx(), new OAuth2Options() + .setSite("https://okta.okta.com/oauth2/default") + .setClientId("client-id") + .setClientSecret("client-secret")) + .onFailure(should::fail) + .onSuccess(oauth2 -> { + OAuth2Options discovered = ((OAuth2AuthProviderImpl) oauth2).getConfig(); + assertSameEndpoints(should, expected, discovered); + should.assertNotNull(discovered.getAuthorizationPath()); + should.assertNotNull(discovered.getTokenPath()); + should.assertNotNull(discovered.getUserInfoPath()); + should.assertNotNull(discovered.getRevocationPath()); + should.assertNotNull(discovered.getIntrospectionPath()); + should.assertNotNull(discovered.getLogoutPath()); + should.assertNotNull(discovered.getJwkPath()); + should.assertEquals("https://okta.okta.com/oauth2/default", discovered.getJWTOptions().getIssuer()); + test.complete(); + }); + } + } diff --git a/vertx-auth-oauth2/src/test/java/io/vertx/tests/ProviderConfigTest.java b/vertx-auth-oauth2/src/test/java/io/vertx/tests/ProviderConfigTest.java new file mode 100644 index 000000000..b8fe26a8c --- /dev/null +++ b/vertx-auth-oauth2/src/test/java/io/vertx/tests/ProviderConfigTest.java @@ -0,0 +1,127 @@ +package io.vertx.tests; + +import io.vertx.ext.auth.oauth2.OAuth2Auth; +import io.vertx.ext.auth.oauth2.OAuth2Options; +import io.vertx.ext.auth.oauth2.impl.OAuth2AuthProviderImpl; +import io.vertx.ext.auth.oauth2.providers.DiscordAuth; +import io.vertx.ext.auth.oauth2.providers.OktaAuth; +import io.vertx.ext.auth.oauth2.providers.OryAuth; +import io.vertx.ext.auth.oauth2.providers.SpotifyAuth; +import io.vertx.ext.auth.oauth2.providers.TwitchAuth; +import io.vertx.ext.unit.junit.RunTestOnContext; +import io.vertx.ext.unit.junit.VertxUnitRunner; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; + +import static org.junit.Assert.*; + +/** + * Verifies that the provider shortcuts configure the endpoints documented by each provider. + * No network access is required: only the resulting {@link OAuth2Options} are inspected. + */ +@RunWith(VertxUnitRunner.class) +public class ProviderConfigTest { + + @Rule + public final RunTestOnContext rule = new RunTestOnContext(); + + private static OAuth2Options configOf(OAuth2Auth auth) { + return ((OAuth2AuthProviderImpl) auth).getConfig(); + } + + @Test + public void testOryNetwork() { + OAuth2Options cfg = configOf(OryAuth.create(rule.vertx(), "https://my-slug.projects.oryapis.com", "id", "secret")); + assertEquals("id", cfg.getClientId()); + assertEquals("secret", cfg.getClientSecret()); + assertEquals("https://my-slug.projects.oryapis.com", cfg.getSite()); + assertEquals("/oauth2/auth", cfg.getAuthorizationPath()); + assertEquals("/oauth2/token", cfg.getTokenPath()); + assertEquals("/userinfo", cfg.getUserInfoPath()); + assertEquals("/oauth2/revoke", cfg.getRevocationPath()); + assertEquals("/oauth2/sessions/logout", cfg.getLogoutPath()); + assertEquals("/.well-known/jwks.json", cfg.getJwkPath()); + assertEquals(" ", cfg.getScopeSeparator()); + } + + @Test + public void testOrySelfHostedTrailingSlash() { + // self hosted hydra public API, trailing slash must be tolerated + OAuth2Options cfg = configOf(OryAuth.create(rule.vertx(), "http://localhost:4444/", "id", "secret")); + assertEquals("http://localhost:4444", cfg.getSite()); + assertEquals("/oauth2/token", cfg.getTokenPath()); + } + + @Test + public void testOktaOrgAuthorizationServer() { + OAuth2Options cfg = configOf(OktaAuth.create(rule.vertx(), "id", "secret", "dev-123456.okta.com")); + assertEquals("id", cfg.getClientId()); + assertEquals("secret", cfg.getClientSecret()); + assertEquals("https://dev-123456.okta.com", cfg.getSite()); + assertEquals("/oauth2/v1/authorize", cfg.getAuthorizationPath()); + assertEquals("/oauth2/v1/token", cfg.getTokenPath()); + assertEquals("/oauth2/v1/userinfo", cfg.getUserInfoPath()); + assertEquals("/oauth2/v1/revoke", cfg.getRevocationPath()); + assertEquals("/oauth2/v1/introspect", cfg.getIntrospectionPath()); + assertEquals("/oauth2/v1/keys", cfg.getJwkPath()); + assertEquals("/oauth2/v1/logout", cfg.getLogoutPath()); + assertEquals(" ", cfg.getScopeSeparator()); + } + + @Test + public void testOktaCustomAuthorizationServer() { + OAuth2Options cfg = configOf(OktaAuth.create(rule.vertx(), "id", "secret", "dev-123456.okta.com", "default")); + assertEquals("https://dev-123456.okta.com/oauth2/default", cfg.getSite()); + assertEquals("/v1/authorize", cfg.getAuthorizationPath()); + assertEquals("/v1/token", cfg.getTokenPath()); + assertEquals("/v1/userinfo", cfg.getUserInfoPath()); + assertEquals("/v1/revoke", cfg.getRevocationPath()); + assertEquals("/v1/introspect", cfg.getIntrospectionPath()); + assertEquals("/v1/keys", cfg.getJwkPath()); + assertEquals("/v1/logout", cfg.getLogoutPath()); + } + + @Test + public void testDiscord() { + OAuth2Options cfg = configOf(DiscordAuth.create(rule.vertx(), "id", "secret")); + assertEquals("id", cfg.getClientId()); + assertEquals("secret", cfg.getClientSecret()); + assertEquals("https://discord.com/api", cfg.getSite()); + assertEquals("https://discord.com/oauth2/authorize", cfg.getAuthorizationPath()); + assertEquals("/oauth2/token", cfg.getTokenPath()); + assertEquals("/oauth2/token/revoke", cfg.getRevocationPath()); + assertEquals("/users/@me", cfg.getUserInfoPath()); + assertEquals(" ", cfg.getScopeSeparator()); + } + + @Test + public void testTwitch() { + OAuth2Options cfg = configOf(TwitchAuth.create(rule.vertx(), "id", "secret")); + assertEquals("id", cfg.getClientId()); + assertEquals("secret", cfg.getClientSecret()); + assertEquals("https://id.twitch.tv/oauth2", cfg.getSite()); + assertEquals("/authorize", cfg.getAuthorizationPath()); + assertEquals("/token", cfg.getTokenPath()); + assertEquals("/userinfo", cfg.getUserInfoPath()); + assertEquals("/revoke", cfg.getRevocationPath()); + assertEquals("/keys", cfg.getJwkPath()); + assertEquals(" ", cfg.getScopeSeparator()); + // twitch only accepts client credentials in the request body + assertFalse(cfg.isUseBasicAuthorization()); + } + + @Test + public void testSpotify() { + OAuth2Options cfg = configOf(SpotifyAuth.create(rule.vertx(), "id", "secret")); + assertEquals("id", cfg.getClientId()); + assertEquals("secret", cfg.getClientSecret()); + assertEquals("https://accounts.spotify.com", cfg.getSite()); + assertEquals("/authorize", cfg.getAuthorizationPath()); + assertEquals("/api/token", cfg.getTokenPath()); + assertEquals("https://api.spotify.com/v1/me", cfg.getUserInfoPath()); + assertEquals(" ", cfg.getScopeSeparator()); + // spotify requires HTTP basic client authentication on the token endpoint + assertTrue(cfg.isUseBasicAuthorization()); + } +}