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"