diff --git a/api/src/routes/auth.js b/api/src/routes/auth.js index 59b1e7a..a3e310c 100644 --- a/api/src/routes/auth.js +++ b/api/src/routes/auth.js @@ -284,4 +284,29 @@ router.get( }), ); +/** + * Metadata endpoint to inform clients about authorization server capabilities, + * according to https://tools.ietf.org/html/rfc8414. + */ +router.get( + '/.well-known/oauth-authorization-server', + wrapRoute(async (req, res) => { + const baseUrl = 'http://localhost:3000'; + + return res.json({ + issuer: baseUrl, + authorization_endpoint: `${baseUrl}/authorize`, + token_endpoint: `${baseUrl}/token`, + token_endpoint_auth_methods_supported: ['none'], // only public clients + userinfo_endpoint: `${baseUrl}/api/user`, + // registration_endpoint: `${baseUrl}/register`, // TODO + // scopes_supported: ALL_SCOPE_NAMES, // TODO + response_types_supported: ['code'], // only auth code, no implicit flow or + service_documentation: 'https://github.com/openbikesensor/portal', + ui_locales_supported: ['en-US', 'en-GB', 'en-CA', 'fr-FR', 'fr-CA'], + code_challenge_methods_supported: ['S256'], + }); + }), +); + module.exports = router; diff --git a/frontend/src/api.js b/frontend/src/api.js index e04cd99..4f21817 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -10,6 +10,51 @@ class API { this._getValidAccessTokenPromise = null } + /** + * Fetches or directly returns from cache the metadata information from the + * authorization server, according to https://tools.ietf.org/html/rfc8414. + * Also validates compatibility with this metadata server, i.e. checking that + * it supports PKCE. + */ + async getAuthorizationServerMetadata() { + const url = new URL(config.auth.server) + const pathSuffix = url.pathname.replace(/^\/+|\/+$/, '') + url.pathname = '/.well-known/oauth-authorization-server' + (pathSuffix ? '/' + pathSuffix : '') + + const response = await window.fetch(url.toString()) + const metadata = await response.json() + + const {authorization_endpoint: authorizationEndpoint, token_endpoint: tokenEndpoint, + response_types_supported: responseTypesSupported, + code_challenge_methods_supported: codeChallengeMethodsSupported, + } = metadata + if (!authorizationEndpoint) { + throw new Error('No authorization endpoint'); + } + + if (!authorizationEndpoint.startsWith(config.auth.server)) { + throw new Error('Invalid authorization endpoint'); + } + + if (!tokenEndpoint) { + throw new Error('No token endpoint'); + } + + if (!tokenEndpoint.startsWith(config.auth.server)) { + throw new Error('Invalid token endpoint'); + } + + if (!Array.isArray(responseTypesSupported) || !responseTypesSupported.includes('code')) { + throw new Error('Authorization code flow not supported or no support advertised.'); + } + + if (!Array.isArray(codeChallengeMethodsSupported) || !codeChallengeMethodsSupported.includes('S256')) { + throw new Error('PKCE with S256 not supported or no support advertised.'); + } + + return {authorizationEndpoint, tokenEndpoint} + } + /** * Return an access token, if it is (still) valid. If not, and a refresh * token exists, use the refresh token to issue a new access token. If that @@ -47,7 +92,8 @@ class API { } // Try to use the refresh token - const url = new URL(config.auth.tokenEndpoint) + const {tokenEndpoint} = await this.getAuthorizationServerMetadata() + const url = new URL(tokenEndpoint) url.searchParams.append('refresh_token', refreshToken) url.searchParams.append('grant_type', 'refresh_token') url.searchParams.append('scope', config.auth.scope) @@ -66,7 +112,8 @@ class API { } async exchangeAuthorizationCode(code) { - const url = new URL(config.auth.tokenEndpoint) + const {tokenEndpoint} = await this.getAuthorizationServerMetadata() + const url = new URL(tokenEndpoint) url.searchParams.append('code', code) url.searchParams.append('grant_type', 'authorization_code') url.searchParams.append('client_id', config.auth.clientId) @@ -87,8 +134,10 @@ class API { return true } - getLoginUrl() { - const loginUrl = new URL(config.auth.authorizationEndpoint) + async makeLoginUrl() { + const {authorizationEndpoint} = await this.getAuthorizationServerMetadata() + + const loginUrl = new URL(authorizationEndpoint) loginUrl.searchParams.append('client_id', config.auth.clientId) loginUrl.searchParams.append('scope', config.auth.scope) loginUrl.searchParams.append('redirect_uri', config.auth.redirectUri) diff --git a/frontend/src/components/LoginButton.js b/frontend/src/components/LoginButton.js index dbe3c4a..1bc603f 100644 --- a/frontend/src/components/LoginButton.js +++ b/frontend/src/components/LoginButton.js @@ -1,10 +1,18 @@ +import React from 'react' import {Button} from 'semantic-ui-react' import api from 'api' export default function LoginButton(props) { - // TODO: Implement PKCE, generate login URL when clicked (with challenge), - // and then redirect there. - const href = api.getLoginUrl() - return + const [busy, setBusy] = React.useState(false) + + const onClick = React.useCallback(async (e) => { + e.preventDefault() + setBusy(true) + const url = await api.makeLoginUrl() + window.location.href = url + setBusy(false) + }, [setBusy]) + + return } diff --git a/frontend/src/config.json b/frontend/src/config.json index 679f41e..5f4a87d 100644 --- a/frontend/src/config.json +++ b/frontend/src/config.json @@ -1,7 +1,6 @@ { "auth": { - "authorizationEndpoint": "http://localhost:3000/authorize", - "tokenEndpoint": "http://localhost:3000/token", + "server": "http://localhost:3000", "clientId": "123", "scope": "*", "redirectUri": "http://localhost:3001/redirect"