api: every OAuth error Ghost answers carries an error_descriptiond9061f82 days ago172 lines · 13.3 KB | 1 | import { type Database, schema } from '@ghost/db'; |
| 2 | import type { INestApplication |
| 3 | import { and, desc |
| 4 | import request from 'supertest |
| 5 | import { afterAll, beforeAll |
| 6 | |
| 7 | import { DATABASE } |
| 8 | import { hashRefreshToken } |
| 9 | import { hasBackends, signUp |
| 10 | |
| 11 | const CALLBACK = ' |
| 12 | const DEVICE_GRANT = ' |
| 13 | |
| 14 | describe.skipIf(!hasBackends |
| 15 | let app: |
| 16 | let db: |
| 17 | let owner: |
| 18 | let clientId: |
| 19 | let clientSecret: |
| 20 | const api = |
| 21 | const form = |
| 22 | const register = |
| 23 | const codeFor = |
| 24 | const first = |
| 25 | const plugin = |
| 26 | let callback = |
| 27 | if (callback.searchParams |
| 28 | return callback.searchParams |
| 29 | }; |
| 30 | const exchange = |
| 31 | const refresh = |
| 32 | const newestKey = |
| 33 | |
| 34 | beforeAll(async () |
| 35 | ({ app } |
| 36 | db = app. |
| 37 | owner = await |
| 38 | ({ clientId, clientSecret |
| 39 | }); |
| 40 | |
| 41 | afterAll(async () |
| 42 | await app?.close |
| 43 | }); |
| 44 | |
| 45 | describe('issuing', |
| 46 | it('adds no expiry and no refresh token while the switch is off', |
| 47 | const off = |
| 48 | const token = |
| 49 | expect(token).toEqual |
| 50 | expect((await newestKey |
| 51 | }); |
| 52 | |
| 53 | it("answers the code grant with GitHub's expiring token fields, form-encoded and JSON", |
| 54 | const token = |
| 55 | expect(token).toEqual |
| 56 | const json = |
| 57 | expect(json).toMatchObject |
| 58 | const [row] |
| 59 | expect(row?.expiresAt |
| 60 | }); |
| 61 | |
| 62 | it('gives the key an 8-hour expiry that the token guard enforces', |
| 63 | const token = |
| 64 | const key = |
| 65 | expect((key?.expiresAt |
| 66 | await api().get |
| 67 | await db.update |
| 68 | await api().get |
| 69 | }); |
| 70 | |
| 71 | it('answers the device grant with a refresh token too', |
| 72 | const device = |
| 73 | const code = |
| 74 | await api().get |
| 75 | await api().post |
| 76 | const token = |
| 77 | expect(token).toMatchObject |
| 78 | }); |
| 79 | }); |
| 80 | |
| 81 | describe('refreshing', |
| 82 | const pair = |
| 83 | |
| 84 | it('trades a refresh token for a new pair with the same scopes, and retires the old pair', |
| 85 | const first = |
| 86 | const next = |
| 87 | expect(next).toEqual |
| 88 | expect(next.access_token |
| 89 | const user = |
| 90 | expect(user.headers |
| 91 | await api().get |
| 92 | expect(form((await |
| 93 | }); |
| 94 | |
| 95 | it('answers JSON when asked', |
| 96 | const response = |
| 97 | expect(response.body |
| 98 | }); |
| 99 | |
| 100 | it('lets only one of two concurrent refreshes with the same token win', |
| 101 | const { refresh_token |
| 102 | const answers = |
| 103 | const tokens = |
| 104 | expect(tokens.filter |
| 105 | expect(tokens.filter |
| 106 | }); |
| 107 | |
| 108 | it('refuses an expired refresh token and an unknown one', |
| 109 | const { refresh_token |
| 110 | await db.update |
| 111 | expect(form((await |
| 112 | expect(form((await |
| 113 | }); |
| 114 | |
| 115 | it("refuses another app's refresh token and leaves it working for its own app", |
| 116 | const { refresh_token |
| 117 | const other = |
| 118 | expect(form((await |
| 119 | expect(form((await |
| 120 | }); |
| 121 | |
| 122 | it('refuses a wrong or missing client secret, and an unknown client, and spends nothing', |
| 123 | const { refresh_token |
| 124 | expect(form((await |
| 125 | const missing = |
| 126 | expect(form(missing |
| 127 | expect(form((await |
| 128 | expect(form((await |
| 129 | }); |
| 130 | |
| 131 | it('stops refreshing once the user revokes the app', |
| 132 | const revoked = |
| 133 | const first = |
| 134 | await api().delete |
| 135 | expect(form((await |
| 136 | }); |
| 137 | |
| 138 | it('answers a refresh_token or client_secret that is not a string with an OAuth error, never a 500', |
| 139 | const { refresh_token |
| 140 | const missing = |
| 141 | expect(missing.body |
| 142 | await api().post |
| 143 | const secret = |
| 144 | expect(secret.body |
| 145 | expect(form((await |
| 146 | }); |
| 147 | |
| 148 | it('leaves nothing behind when a refresh races the user revoking the app', |
| 149 | const raced = |
| 150 | const credentials = |
| 151 | for (let round |
| 152 | const { refresh_token |
| 153 | await Promise.all |
| 154 | const keys = |
| 155 | const tokens = |
| 156 | expect({ round, |
| 157 | } |
| 158 | }); |
| 159 | |
| 160 | it('moves to keys that never expire once the switch is turned off', |
| 161 | const toggled = |
| 162 | const credentials = |
| 163 | const first = |
| 164 | await api().patch |
| 165 | const next = |
| 166 | expect(next).toEqual |
| 167 | expect((await newestKey |
| 168 | await api().get |
| 169 | expect(form((await |
| 170 | }); |
| 171 | }); |
| 172 | }); |
}
from
'
@nestjs/common
'
;
,
eq
}
from
'
drizzle-orm
'
;
'
;
,
describe
,
expect
,
it
}
from
'
vitest
'
;
from
'
../src/database/database.module.js
'
;
from
'
../src/lib/github/refresh-tokens.js
'
;
,
startApp
}
from
'
./harness.js
'
;
https://tool.example/oauth/callback
'
;
urn:ietf:params:oauth:grant-type:device_code
'
;
)(
'
GitHub OAuth expiring tokens and refresh
'
,
()
=>
{
INestApplication
;
Database
;
{
cookie
:
string
;
key
:
string
;
userId
:
string
}
;
string
;
string
;
()
=>
request
(
app
.
getHttpServer
())
;
(
text
:
string
)
=>
Object
.
fromEntries
(
new
URLSearchParams
(
text
))
;
async
(
body
:
object
=
{})
=>
(
await
api
()
.
post
(
'
/api/oauth-apps
'
)
.
set
(
'
cookie
'
,
owner
.
cookie
)
.
send
(
{
name
:
'
Expiring Tool
'
,
homepageUrl
:
'
https://tool.example
'
,
callbackUrls
:
[
CALLBACK
]
,
expireUserTokens
:
true
,
...
body
}
)
.
expect
(
201
))
.
body
as
{
clientId
:
string
;
clientSecret
:
string
}
;
async
(
client
:
string
)
=>
{
await
api
()
.
get
(
'
/login/oauth/authorize
'
)
.
query
(
{
client_id
:
client
,
scope
:
'
repo
'
}
)
.
set
(
'
cookie
'
,
owner
.
cookie
)
.
expect
(
302
)
;
new
URL
(
first
.
headers
.
location
,
'
http://api.local
'
)
;
new
URL
((
await
api
()
.
get
(
`
${
plugin
.
pathname
}${
plugin
.
search
}
`
)
.
set
(
'
cookie
'
,
owner
.
cookie
)
.
expect
(
302
))
.
headers
.
location
)
;
.
has
(
'
sig
'
))
callback
=
new
URL
((
await
api
()
.
post
(
'
/api/auth/oauth2/consent
'
)
.
set
(
'
cookie
'
,
owner
.
cookie
)
.
send
(
{
accept
:
true
,
oauth_query
:
callback
.
search
.
slice
(
1
)
}
)
.
expect
(
200
))
.
body
.
url
)
;
.
get
(
'
code
'
)
as
string
;
async
(
client
=
clientId
,
secret
=
clientSecret
,
accept
=
'
application/x-www-form-urlencoded
'
)
=>
api
()
.
post
(
'
/login/oauth/access_token
'
)
.
set
(
'
accept
'
,
accept
)
.
type
(
'
form
'
)
.
send
(
{
client_id
:
client
,
client_secret
:
secret
,
code
:
await
codeFor
(
client
)
}
)
.
expect
(
200
)
;
(
refreshToken
:
string
,
body
:
Record
<
string
,
string
>
=
{})
=>
api
()
.
post
(
'
/login/oauth/access_token
'
)
.
type
(
'
form
'
)
.
send
(
{
client_id
:
clientId
,
client_secret
:
clientSecret
,
grant_type
:
'
refresh_token
'
,
refresh_token
:
refreshToken
,
...
body
}
)
.
expect
(
200
)
;
async
()
=>
(
await
db
.
select
(
{
id
:
schema
.
apikey
.
id
,
expiresAt
:
schema
.
apikey
.
expiresAt
}
)
.
from
(
schema
.
apikey
)
.
where
(
eq
(
schema
.
apikey
.
referenceId
,
owner
.
userId
))
.
orderBy
(
desc
(
schema
.
apikey
.
createdAt
))
.
limit
(
1
))[
0
]
;
=>
{
=
await
startApp
(
{
WEB_APP_URL
:
'
https://web.example
'
}
))
;
get
<
Database
>
(
DATABASE
)
;
signUp
(
app
,
`
ghrt
${
Date
.
now
()
}
`
)
;
}
=
await
register
())
;
=>
{
()
;
()
=>
{
async
()
=>
{
await
register
(
{
expireUserTokens
:
false
}
)
;
form
((
await
exchange
(
off
.
clientId
,
off
.
clientSecret
))
.
text
)
;
(
{
access_token
:
expect
.
stringMatching
(
/
^
ghost_pat_
/
)
,
token_type
:
'
bearer
'
,
scope
:
'
repo
'
}
)
;
())
?.
expiresAt
)
.
toBeNull
()
;
async
()
=>
{
form
((
await
exchange
())
.
text
)
;
(
{
access_token
:
expect
.
stringMatching
(
/
^
ghost_pat_
/
)
,
expires_in
:
'
28800
'
,
refresh_token
:
expect
.
stringMatching
(
/
^
ghost_rt_
/
)
,
refresh_token_expires_in
:
'
15897600
'
,
token_type
:
'
bearer
'
,
scope
:
'
repo
'
}
)
;
(
await
exchange
(
clientId
,
clientSecret
,
'
application/json
'
))
.
body
;
(
{
expires_in
:
28800
,
refresh_token_expires_in
:
15897600
,
refresh_token
:
expect
.
stringMatching
(
/
^
ghost_rt_
/
)
}
)
;
=
await
db
.
select
(
{
expiresAt
:
schema
.
oauthAppRefreshToken
.
expiresAt
}
)
.
from
(
schema
.
oauthAppRefreshToken
)
.
where
(
eq
(
schema
.
oauthAppRefreshToken
.
tokenHash
,
hashRefreshToken
(
json
.
refresh_token
)))
;
.
getTime
())
.
toBeGreaterThan
(
Date
.
now
()
+
15897600
*
1000
-
60_000
)
;
async
()
=>
{
form
((
await
exchange
())
.
text
)
;
await
newestKey
()
;
as
Date
)
.
getTime
()
-
Date
.
now
())
.
toBeGreaterThan
(
28800
*
1000
-
60_000
)
;
(
'
/api/v3/user
'
)
.
set
(
'
authorization
'
,
`
token
${
token
.
access_token
}
`
)
.
expect
(
200
)
;
(
schema
.
apikey
)
.
set
(
{
expiresAt
:
new
Date
(
Date
.
now
()
-
1000
)
}
)
.
where
(
eq
(
schema
.
apikey
.
id
,
key
?.
id
as
string
))
;
(
'
/api/v3/user
'
)
.
set
(
'
authorization
'
,
`
token
${
token
.
access_token
}
`
)
.
expect
(
401
)
;
async
()
=>
{
await
register
(
{
deviceFlowEnabled
:
true
}
)
;
form
((
await
api
()
.
post
(
'
/login/device/code
'
)
.
type
(
'
form
'
)
.
send
(
{
client_id
:
device
.
clientId
,
scope
:
'
repo
'
}
)
.
expect
(
200
))
.
text
)
;
(
'
/api/auth/device
'
)
.
query
(
{
user_code
:
code
.
user_code
}
)
.
set
(
'
cookie
'
,
owner
.
cookie
)
.
expect
(
200
)
;
(
'
/api/auth/device/approve
'
)
.
set
(
'
cookie
'
,
owner
.
cookie
)
.
send
(
{
userCode
:
code
.
user_code
}
)
.
expect
(
200
)
;
form
((
await
api
()
.
post
(
'
/login/oauth/access_token
'
)
.
type
(
'
form
'
)
.
send
(
{
client_id
:
device
.
clientId
,
device_code
:
code
.
device_code
,
grant_type
:
DEVICE_GRANT
}
)
.
expect
(
200
))
.
text
)
;
(
{
expires_in
:
'
28800
'
,
refresh_token
:
expect
.
stringMatching
(
/
^
ghost_rt_
/
)
,
refresh_token_expires_in
:
'
15897600
'
,
scope
:
'
repo
'
}
)
;
()
=>
{
async
()
=>
form
((
await
exchange
())
.
text
)
;
async
()
=>
{
await
pair
()
;
form
((
await
refresh
(
first
.
refresh_token
as
string
))
.
text
)
;
(
{
access_token
:
expect
.
stringMatching
(
/
^
ghost_pat_
/
)
,
expires_in
:
'
28800
'
,
refresh_token
:
expect
.
stringMatching
(
/
^
ghost_rt_
/
)
,
refresh_token_expires_in
:
'
15897600
'
,
token_type
:
'
bearer
'
,
scope
:
'
repo
'
}
)
;
)
.
not
.
toBe
(
first
.
access_token
)
;
await
api
()
.
get
(
'
/api/v3/user
'
)
.
set
(
'
authorization
'
,
`
token
${
next
.
access_token
}
`
)
.
expect
(
200
)
;
[
'
x-oauth-scopes
'
])
.
toBe
(
'
repo
'
)
;
(
'
/api/v3/user
'
)
.
set
(
'
authorization
'
,
`
token
${
first
.
access_token
}
`
)
.
expect
(
401
)
;
refresh
(
first
.
refresh_token
as
string
))
.
text
))
.
toEqual
(
{
error
:
'
bad_refresh_token
'
,
error_description
:
'
The refresh token passed is incorrect or expired.
'
}
)
;
async
()
=>
{
await
refresh
((
await
pair
())
.
refresh_token
as
string
)
.
set
(
'
accept
'
,
'
application/json
'
)
;
)
.
toMatchObject
(
{
expires_in
:
28800
,
refresh_token_expires_in
:
15897600
,
token_type
:
'
bearer
'
}
)
;
async
()
=>
{
}
=
await
pair
()
;
await
Promise
.
all
([
refresh
(
refresh_token
as
string
)
,
refresh
(
refresh_token
as
string
)])
;
answers
.
map
(
(
answer
)
=>
form
(
answer
.
text
))
;
(
(
token
)
=>
token
.
access_token
))
.
toHaveLength
(
1
)
;
(
(
token
)
=>
token
.
error
===
'
bad_refresh_token
'
))
.
toHaveLength
(
1
)
;
async
()
=>
{
}
=
await
pair
()
;
(
schema
.
oauthAppRefreshToken
)
.
set
(
{
expiresAt
:
new
Date
(
Date
.
now
()
-
1000
)
}
)
.
where
(
eq
(
schema
.
oauthAppRefreshToken
.
tokenHash
,
hashRefreshToken
(
refresh_token
as
string
)))
;
refresh
(
refresh_token
as
string
))
.
text
)
.
error
)
.
toBe
(
'
bad_refresh_token
'
)
;
refresh
(
'
ghost_rt_nope
'
))
.
text
)
.
error
)
.
toBe
(
'
bad_refresh_token
'
)
;
async
()
=>
{
}
=
await
pair
()
;
await
register
(
{
name
:
'
Other Tool
'
}
)
;
refresh
(
refresh_token
as
string
,
{
client_id
:
other
.
clientId
,
client_secret
:
other
.
clientSecret
}
))
.
text
)
.
error
)
.
toBe
(
'
bad_refresh_token
'
)
;
refresh
(
refresh_token
as
string
))
.
text
)
.
access_token
)
.
toMatch
(
/
^
ghost_pat_
/
)
;
async
()
=>
{
}
=
await
pair
()
;
refresh
(
refresh_token
as
string
,
{
client_secret
:
'
wrong
'
}
))
.
text
))
.
toMatchObject
(
{
error
:
'
incorrect_client_credentials
'
,
error_description
:
'
The client_id and/or client_secret passed are incorrect.
'
}
)
;
await
api
()
.
post
(
'
/login/oauth/access_token
'
)
.
type
(
'
form
'
)
.
send
(
{
client_id
:
clientId
,
grant_type
:
'
refresh_token
'
,
refresh_token
}
)
.
expect
(
200
)
;
.
text
))
.
toMatchObject
(
{
error
:
'
incorrect_client_credentials
'
,
error_description
:
'
The client_id and/or client_secret passed are incorrect.
'
}
)
;
refresh
(
refresh_token
as
string
,
{
client_id
:
'
nope
'
}
))
.
text
))
.
toMatchObject
(
{
error
:
'
incorrect_client_credentials
'
,
error_description
:
'
The client_id and/or client_secret passed are incorrect.
'
}
)
;
refresh
(
refresh_token
as
string
))
.
text
)
.
access_token
)
.
toMatch
(
/
^
ghost_pat_
/
)
;
async
()
=>
{
await
register
(
{
name
:
'
Revoked Tool
'
}
)
;
form
((
await
exchange
(
revoked
.
clientId
,
revoked
.
clientSecret
))
.
text
)
;
(
`
/api/oauth-apps/authorized/
${
revoked
.
clientId
}
`
)
.
set
(
'
cookie
'
,
owner
.
cookie
)
.
expect
(
204
)
;
refresh
(
first
.
refresh_token
as
string
,
{
client_id
:
revoked
.
clientId
,
client_secret
:
revoked
.
clientSecret
}
))
.
text
)
.
error
)
.
toBe
(
'
bad_refresh_token
'
)
;
async
()
=>
{
}
=
await
pair
()
;
await
api
()
.
post
(
'
/login/oauth/access_token
'
)
.
send
(
{
client_id
:
clientId
,
client_secret
:
clientSecret
,
grant_type
:
'
refresh_token
'
}
)
.
expect
(
400
)
;
)
.
toEqual
(
{
error
:
'
unsupported_grant_type
'
,
error_description
:
'
Send a code, a device_code with the device grant_type, or a refresh_token with grant_type=refresh_token.
'
}
)
;
(
'
/login/oauth/access_token
'
)
.
send
(
{
client_id
:
clientId
,
client_secret
:
clientSecret
,
grant_type
:
'
refresh_token
'
,
refresh_token
:
123
}
)
.
expect
(
400
)
;
await
api
()
.
post
(
'
/login/oauth/access_token
'
)
.
set
(
'
accept
'
,
'
application/json
'
)
.
send
(
{
client_id
:
clientId
,
client_secret
:
123
,
grant_type
:
'
refresh_token
'
,
refresh_token
}
)
.
expect
(
200
)
;
)
.
toEqual
(
{
error
:
'
incorrect_client_credentials
'
,
error_description
:
'
The client_id and/or client_secret passed are incorrect.
'
}
)
;
refresh
(
refresh_token
as
string
))
.
text
)
.
access_token
)
.
toMatch
(
/
^
ghost_pat_
/
)
;
async
()
=>
{
await
register
(
{
name
:
'
Raced Tool
'
}
)
;
{
client_id
:
raced
.
clientId
,
client_secret
:
raced
.
clientSecret
}
;
=
0
;
round
<
10
;
round
++
)
{
}
=
form
((
await
exchange
(
raced
.
clientId
,
raced
.
clientSecret
))
.
text
)
;
([
refresh
(
refresh_token
as
string
,
credentials
)
,
api
()
.
delete
(
`
/api/oauth-apps/authorized/
${
raced
.
clientId
}
`
)
.
set
(
'
cookie
'
,
owner
.
cookie
)
.
expect
(
204
)])
;
await
db
.
select
(
{
id
:
schema
.
apikey
.
id
}
)
.
from
(
schema
.
apikey
)
.
where
(
and
(
eq
(
schema
.
apikey
.
referenceId
,
owner
.
userId
)
,
eq
(
schema
.
apikey
.
name
,
'
Raced Tool
'
)))
;
await
db
.
select
(
{
id
:
schema
.
oauthAppRefreshToken
.
id
}
)
.
from
(
schema
.
oauthAppRefreshToken
)
.
where
(
eq
(
schema
.
oauthAppRefreshToken
.
clientId
,
raced
.
clientId
))
;
keys
,
tokens
}
)
.
toEqual
(
{
round
,
keys
:
[]
,
tokens
:
[]
}
)
;
async
()
=>
{
await
register
(
{
name
:
'
Toggled Tool
'
}
)
;
{
client_id
:
toggled
.
clientId
,
client_secret
:
toggled
.
clientSecret
}
;
form
((
await
exchange
(
toggled
.
clientId
,
toggled
.
clientSecret
))
.
text
)
;
(
`
/api/oauth-apps/
${
toggled
.
clientId
}
`
)
.
set
(
'
cookie
'
,
owner
.
cookie
)
.
send
(
{
expireUserTokens
:
false
}
)
.
expect
(
200
)
;
form
((
await
refresh
(
first
.
refresh_token
as
string
,
credentials
))
.
text
)
;
(
{
access_token
:
expect
.
stringMatching
(
/
^
ghost_pat_
/
)
,
token_type
:
'
bearer
'
,
scope
:
'
repo
'
}
)
;
())
?.
expiresAt
)
.
toBeNull
()
;
(
'
/api/v3/user
'
)
.
set
(
'
authorization
'
,
`
token
${
first
.
access_token
}
`
)
.
expect
(
401
)
;
refresh
(
first
.
refresh_token
as
string
,
credentials
))
.
text
)
.
error
)
.
toBe
(
'
bad_refresh_token
'
)
;