-
Notifications
You must be signed in to change notification settings - Fork 157
feat: URL client id for client credentials #2041
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
091ac9f
86f70ba
7eaf6c3
b0bdff0
3a2d9d7
e3b6292
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -2,14 +2,16 @@ import { v4 } from 'uuid'; | |||||
| import { object, string } from 'yup'; | ||||||
| import { getLoggerFor } from '../../../logging/LogUtil'; | ||||||
| import { BadRequestHttpError } from '../../../util/errors/BadRequestHttpError'; | ||||||
| import { sanitizeUrlPart } from '../../../util/StringUtil'; | ||||||
| import { ConflictHttpError } from '../../../util/errors/ConflictHttpError'; | ||||||
| import { assertAccountId } from '../account/util/AccountUtil'; | ||||||
| import type { JsonRepresentation } from '../InteractionUtil'; | ||||||
| import { JsonInteractionHandler } from '../JsonInteractionHandler'; | ||||||
| import type { JsonInteractionHandlerInput } from '../JsonInteractionHandler'; | ||||||
| import type { JsonView } from '../JsonView'; | ||||||
| import type { WebIdStore } from '../webid/util/WebIdStore'; | ||||||
| import { parseSchema, validateWithError } from '../YupUtil'; | ||||||
| import type { OwnershipValidator } from '../../ownership/OwnershipValidator'; | ||||||
| import { isUrl, sanitizeUrlPart } from '../../../util/StringUtil'; | ||||||
| import type { ClientCredentialsIdRoute } from './util/ClientCredentialsIdRoute'; | ||||||
| import type { ClientCredentialsStore } from './util/ClientCredentialsStore'; | ||||||
|
|
||||||
|
|
@@ -30,19 +32,13 @@ type OutType = { | |||||
| export class CreateClientCredentialsHandler extends JsonInteractionHandler<OutType> implements JsonView { | ||||||
| protected readonly logger = getLoggerFor(this); | ||||||
|
|
||||||
| private readonly webIdStore: WebIdStore; | ||||||
| private readonly clientCredentialsStore: ClientCredentialsStore; | ||||||
| private readonly clientCredentialsRoute: ClientCredentialsIdRoute; | ||||||
|
|
||||||
| public constructor( | ||||||
| webIdStore: WebIdStore, | ||||||
| clientCredentialsStore: ClientCredentialsStore, | ||||||
| clientCredentialsRoute: ClientCredentialsIdRoute, | ||||||
| private readonly webIdStore: WebIdStore, | ||||||
| private readonly clientCredentialsStore: ClientCredentialsStore, | ||||||
| private readonly clientCredentialsRoute: ClientCredentialsIdRoute, | ||||||
| private readonly ownershipValidator: OwnershipValidator, | ||||||
| ) { | ||||||
| super(); | ||||||
| this.webIdStore = webIdStore; | ||||||
| this.clientCredentialsStore = clientCredentialsStore; | ||||||
| this.clientCredentialsRoute = clientCredentialsRoute; | ||||||
| } | ||||||
|
|
||||||
| public async getView({ accountId }: JsonInteractionHandlerInput): Promise<JsonRepresentation> { | ||||||
|
|
@@ -64,8 +60,19 @@ export class CreateClientCredentialsHandler extends JsonInteractionHandler<OutTy | |||||
| throw new BadRequestHttpError('WebID does not belong to this account.'); | ||||||
| } | ||||||
|
|
||||||
| const cleanedName = name ? sanitizeUrlPart(name.trim()) : ''; | ||||||
| const label = `${cleanedName}_${v4()}`; | ||||||
| let label: string; | ||||||
|
|
||||||
| if (name && isUrl(name)) { | ||||||
| // Validate owneship first not to leak existence of credentials! | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Thanks! I will most likely need to add a test case for that so I'll include your suggestion in that commit. |
||||||
| await this.ownershipValidator.handleSafe({ webId: name }); | ||||||
| if (await this.clientCredentialsStore.findByLabel(name)) { | ||||||
| throw new ConflictHttpError('Token for this ClientID already exists.'); | ||||||
| } | ||||||
| label = name; | ||||||
| } else { | ||||||
| const cleanedName = name ? sanitizeUrlPart(name.trim()) : ''; | ||||||
| label = `${cleanedName}_${v4()}`; | ||||||
| } | ||||||
|
|
||||||
| const { secret, id } = await this.clientCredentialsStore.create(label, webId, accountId); | ||||||
| const resource = this.clientCredentialsRoute.getPath({ accountId, clientCredentialsId: id }); | ||||||
|
|
||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -7,6 +7,8 @@ import { | |
| import { fetch } from 'cross-fetch'; | ||
| import { parse, splitCookiesString } from 'set-cookie-parser'; | ||
| import type { App } from '../../src/init/App'; | ||
| import type { ResourceStore } from '../../src/storage/ResourceStore'; | ||
| import { BasicRepresentation } from '../../src/http/representation/BasicRepresentation'; | ||
| import { APPLICATION_X_WWW_FORM_URLENCODED } from '../../src/util/ContentTypes'; | ||
| import { joinUrl } from '../../src/util/PathUtil'; | ||
| import { register } from '../util/AccountUtil'; | ||
|
|
@@ -38,6 +40,7 @@ jest.spyOn(process, 'emitWarning').mockImplementation(); | |
| // We also need to parse the HTML in several steps since there is no API. | ||
| describe.each(stores)('A Solid server with IDP using %s', (name, { config, teardown }): void => { | ||
| let app: App; | ||
| let store: ResourceStore; | ||
| const redirectUrl = 'http://mockedredirect/'; | ||
| const container = new URL('secret/', baseUrl).href; | ||
| const oidcIssuer = baseUrl; | ||
|
|
@@ -63,7 +66,7 @@ describe.each(stores)('A Solid server with IDP using %s', (name, { config, teard | |
| 'urn:solid-server:default:variable:rootFilePath': rootFilePath, | ||
| }, | ||
| ) as Record<string, any>; | ||
| ({ app } = instances); | ||
| ({ app, store } = instances); | ||
| await app.start(); | ||
|
|
||
| // Create accounts | ||
|
|
@@ -339,7 +342,7 @@ describe.each(stores)('A Solid server with IDP using %s', (name, { config, teard | |
| }); | ||
| }); | ||
|
|
||
| describe('using client_credentials', (): void => { | ||
| describe('using client_credentials - string', (): void => { | ||
| const tokenUrl = joinUrl(baseUrl, '.oidc/token'); | ||
| let dpopKey: KeyPair; | ||
| let id: string | undefined; | ||
|
|
@@ -400,6 +403,111 @@ describe.each(stores)('A Solid server with IDP using %s', (name, { config, teard | |
| }); | ||
| }); | ||
|
|
||
| describe('using client_credentials - url', (): void => { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why was this entire block added as it differs on only one line from the previous block. Is it to check for an issue related to the reason the order of the components was changed?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yes, just changing from string to URL was resulting in a different adapter handing it and failing. I though the safest would be to run the whole |
||
| const tokenUrl = joinUrl(baseUrl, '.oidc/token'); | ||
| let dpopKey: KeyPair; | ||
| let id: string | undefined; | ||
| let secret: string | undefined; | ||
| let accessToken: string | undefined; | ||
| const publicContainer = joinUrl(baseUrl, '/public/'); | ||
| const clientId = joinUrl(publicContainer, 'client'); | ||
|
|
||
| beforeAll(async(): Promise<void> => { | ||
| dpopKey = await generateDpopKeyPair(); | ||
| }); | ||
|
|
||
| it('can request a credentials token.', async(): Promise<void> => { | ||
| // Create a public container where we can write any data | ||
| await store.setRepresentation( | ||
| { path: joinUrl(publicContainer, '.acl') }, | ||
| new BasicRepresentation( | ||
| `@prefix acl: <http://www.w3.org/ns/auth/acl#>. | ||
| @prefix foaf: <http://xmlns.com/foaf/0.1/>. | ||
| <#public> | ||
| a acl:Authorization; | ||
| acl:agentClass foaf:Agent; | ||
| acl:accessTo <./>; | ||
| acl:default <./>; | ||
| acl:mode acl:Read, acl:Write, acl:Control.`, | ||
| 'text/turtle', | ||
| ), | ||
| ); | ||
| // Create the ClientId | ||
| const createClientIdRes = await fetch(clientId, { | ||
| method: 'PUT', | ||
| headers: { 'content-type': 'text/turtle' }, | ||
| body: '', | ||
| }); | ||
| expect(createClientIdRes.status).toBe(201); | ||
|
|
||
| // Login and save cookie | ||
| const loginResponse = await fetch(controls.password.login, { | ||
| method: 'POST', | ||
| headers: { 'content-type': 'application/json' }, | ||
| body: JSON.stringify({ email, password }), | ||
| }); | ||
| const cookies = parse(splitCookiesString(loginResponse.headers.get('set-cookie')!)); | ||
| const cookie = `${cookies[0].name}=${cookies[0].value}`; | ||
|
|
||
| // Try to request token | ||
| const accountJson = await (await fetch(indexUrl, { headers: { cookie }})).json(); | ||
| const credentialsUrl = accountJson.controls.account.clientCredentials; | ||
| const failedTokenRes = await fetch(credentialsUrl, { | ||
| method: 'POST', | ||
| headers: { cookie, 'content-type': 'application/json' }, | ||
| body: JSON.stringify({ name: clientId, webId }), | ||
| }); | ||
|
|
||
| // Verification token is missing | ||
| expect(failedTokenRes.status).toBe(400); | ||
| const json = await failedTokenRes.json(); | ||
| expect(json.details?.quad).toBeDefined(); | ||
| const { quad } = json.details; | ||
| // Update the WebID with the identifying quad | ||
| const updateClientIdRes = await fetch(clientId, { | ||
| method: 'PUT', | ||
| headers: { 'content-type': 'text/turtle' }, | ||
| body: quad, | ||
| }); | ||
| expect(updateClientIdRes.status).toBe(205); | ||
|
|
||
| // Try again to request token | ||
| const tokenRes = await fetch(credentialsUrl, { | ||
| method: 'POST', | ||
| headers: { cookie, 'content-type': 'application/json' }, | ||
| body: JSON.stringify({ name: clientId, webId }), | ||
| }); | ||
| expect(tokenRes.status).toBe(200); | ||
| ({ id, secret } = await tokenRes.json()); | ||
| }); | ||
|
|
||
| it('can request an access token using the credentials.', async(): Promise<void> => { | ||
| const dpopHeader = await createDpopHeader(tokenUrl, 'POST', dpopKey); | ||
| const authString = `${encodeURIComponent(id!)}:${encodeURIComponent(secret!)}`; | ||
| const res = await fetch(tokenUrl, { | ||
| method: 'POST', | ||
| headers: { | ||
| authorization: `Basic ${Buffer.from(authString).toString('base64')}`, | ||
| 'content-type': APPLICATION_X_WWW_FORM_URLENCODED, | ||
| dpop: dpopHeader, | ||
| }, | ||
| body: 'grant_type=client_credentials&scope=webid', | ||
| }); | ||
| expect(res.status).toBe(200); | ||
| const json = await res.json(); | ||
| ({ access_token: accessToken } = json); | ||
| expect(typeof accessToken).toBe('string'); | ||
| }); | ||
|
|
||
| it('can use the generated access token to do an authenticated call.', async(): Promise<void> => { | ||
| const authFetch = await buildAuthenticatedFetch(accessToken!, { dpopKey }); | ||
| let res = await fetch(container); | ||
| expect(res.status).toBe(401); | ||
| res = await authFetch(container); | ||
| expect(res.status).toBe(200); | ||
| }); | ||
| }); | ||
|
|
||
| describe('setup', (): void => { | ||
| it('should contain the required configuration keys.', async(): Promise<void> => { | ||
| const res = await fetch(`${baseUrl}.well-known/openid-configuration`); | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What is the reason these needed to be swapped? I'm guessing it has something to do with the other adapter trying to fetch the URL label maybe but can you tell me exactly what goes wrong?
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Without that change
ClientIdAdapterFactorywas trying to fetch that URL, I don't remember if there was a problem if it didn't exist, but when it was able to fetch that ClientID, it was settingtoken_endpoint_auth_methodtononeand complaining that it wasn't allowed and it didn't pick credentials passed viaAuthorizationhearder.I think it doesn't make sense to use
ClientIDAdapterat all whenClientCredentialsAdapterhandles that client properly, that's why in the end I changed the order. As you can see all other tests are still passing so it doesn't seem to be affecting anything elese, and if that client hasn't been registered for client credential for that webid there should be no problem.I haven't tested what happens if that user tries to use the same client both with client credential and with authorization code. Should I add integration test for that?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The ClientIdAdapterFactory was written under the assumption that if the incoming ID is a URL, this is always the class that should handle it, but that breaks here. The "deeper" class always triggers first, so if a client ID is used that is also a credential label the credential adapter will trigger first. But this could potentially cause issues when someone uses a client ID request which happens to also be the label of a credential token (of someone else potentially). So this definitely needs to be investigated what would happen there. I'm not sure if this would work with how the adapters currently work.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Since only one client credential per Client ID is supported with this approach (#2053 could be an alternative) I think a check similar to linking an existing WebID would make sense here. One would have to prove control over client to get a URL Client ID credential for it. Once such credential is created, it can only be used with it and no more authorization code flow for that client.