frontend,api: Expose authorization server metadata and use it

This commit is contained in:
Paul Bienkowski 2021-02-23 18:26:37 +01:00
parent 6ba29e68a0
commit 5ce2947fea
4 changed files with 91 additions and 10 deletions

View file

@ -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; module.exports = router;

View file

@ -10,6 +10,51 @@ class API {
this._getValidAccessTokenPromise = null 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 * 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 * 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 // 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('refresh_token', refreshToken)
url.searchParams.append('grant_type', 'refresh_token') url.searchParams.append('grant_type', 'refresh_token')
url.searchParams.append('scope', config.auth.scope) url.searchParams.append('scope', config.auth.scope)
@ -66,7 +112,8 @@ class API {
} }
async exchangeAuthorizationCode(code) { 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('code', code)
url.searchParams.append('grant_type', 'authorization_code') url.searchParams.append('grant_type', 'authorization_code')
url.searchParams.append('client_id', config.auth.clientId) url.searchParams.append('client_id', config.auth.clientId)
@ -87,8 +134,10 @@ class API {
return true return true
} }
getLoginUrl() { async makeLoginUrl() {
const loginUrl = new URL(config.auth.authorizationEndpoint) const {authorizationEndpoint} = await this.getAuthorizationServerMetadata()
const loginUrl = new URL(authorizationEndpoint)
loginUrl.searchParams.append('client_id', config.auth.clientId) loginUrl.searchParams.append('client_id', config.auth.clientId)
loginUrl.searchParams.append('scope', config.auth.scope) loginUrl.searchParams.append('scope', config.auth.scope)
loginUrl.searchParams.append('redirect_uri', config.auth.redirectUri) loginUrl.searchParams.append('redirect_uri', config.auth.redirectUri)

View file

@ -1,10 +1,18 @@
import React from 'react'
import {Button} from 'semantic-ui-react' import {Button} from 'semantic-ui-react'
import api from 'api' import api from 'api'
export default function LoginButton(props) { export default function LoginButton(props) {
// TODO: Implement PKCE, generate login URL when clicked (with challenge), const [busy, setBusy] = React.useState(false)
// and then redirect there.
const href = api.getLoginUrl() const onClick = React.useCallback(async (e) => {
return <Button as='a' href={href} {...props}>Login</Button> e.preventDefault()
setBusy(true)
const url = await api.makeLoginUrl()
window.location.href = url
setBusy(false)
}, [setBusy])
return <Button onClick={busy ? null : onClick} loading={busy} {...props}>Login</Button>
} }

View file

@ -1,7 +1,6 @@
{ {
"auth": { "auth": {
"authorizationEndpoint": "http://localhost:3000/authorize", "server": "http://localhost:3000",
"tokenEndpoint": "http://localhost:3000/token",
"clientId": "123", "clientId": "123",
"scope": "*", "scope": "*",
"redirectUri": "http://localhost:3001/redirect" "redirectUri": "http://localhost:3001/redirect"